Compare commits

126 Commits

Author SHA1 Message Date
Your Name
f4f964140b fix: close button and CSV import parsing for Clockify/Harvest
Close button did nothing when "close to tray" was disabled - the
onCloseRequested handler lacked an explicit destroy call for the
non-tray path.

Clockify CSV import threw RangeError because locale-dependent date
formats (MM/DD/YYYY, DD.MM.YYYY, 12h time) were passed straight
to the Date constructor. Added flexible date/time parsers that
handle all Clockify export variants without relying on Date parsing.

Added dedicated Clockify mapper that prefers Duration (decimal)
column and a new Harvest CSV importer (date + decimal hours, no
start/end times).

Bump version to 1.0.1.
2026-02-21 14:56:53 +02:00
Your Name
eb0c65c29a feat: complete export/import cycle and remove sample data
Export now includes invoice_payments and recurring_invoices tables.
Import restored to use ID-based lookups and all fields for clients,
projects, tasks, and time entries. Added missing import support for
timeline_events, calendar_sources, calendar_events, invoice_payments,
and recurring_invoices. Export uses native save dialog instead of blob
download. Removed sample data seeding (seed.rs, UI, command).
2026-02-21 01:34:26 +02:00
Your Name
a0bb7d3ea8 chore: add CC0 license file and update readme badge 2026-02-21 01:20:20 +02:00
Your Name
514090eed4 feat: tooltips, two-column timer, font selector, tray behavior, icons, readme
- Custom tooltip directive (WCAG AAA) on every button in the app
- Two-column timer layout with sticky hero and recent entries sidebar
- Timer font selector with 16 monospace Google Fonts and live preview
- UI font selector with 15+ Google Fonts
- Close-to-tray and minimize-to-tray settings
- New app icons (no-glow variants), platform icon set
- Mini timer pop-out window
- Favorites strip with drag-reorder and inline actions
- Comprehensive README with feature documentation
- Remove tracked files that belong in gitignore
2026-02-21 01:15:57 +02:00
Your Name
2608f447de fix: boost text-tertiary contrast for WCAG AAA (7:1) 2026-02-20 18:40:19 +02:00
Your Name
5300ceeb12 feat: auto-backup UI and window close hook 2026-02-20 15:41:38 +02:00
Your Name
875d3ca23b feat: comprehensive export with all tables and auto-backup command 2026-02-20 15:40:02 +02:00
Your Name
fa7b70aa61 feat: rounding visibility in invoices and reports 2026-02-20 15:37:20 +02:00
Your Name
773ba1d338 feat: rounding visibility indicators on entry rows 2026-02-20 15:36:07 +02:00
Your Name
a41ce44f13 feat: time-of-day heatmap in reports patterns tab 2026-02-20 15:32:20 +02:00
Your Name
ace66a6093 feat: project health badges and attention section 2026-02-20 15:32:14 +02:00
Your Name
3fd389da11 feat: weekly comparison indicators and sparklines on dashboard 2026-02-20 15:32:07 +02:00
Your Name
159bb927af feat: receipt thumbnails, lightbox, and file picker for expenses 2026-02-20 15:25:18 +02:00
Your Name
29f29e3368 feat: receipt lightbox component with zoom and focus trap 2026-02-20 15:23:11 +02:00
Your Name
991866f017 feat: global shortcut for quick entry dialog 2026-02-20 15:20:27 +02:00
Your Name
d4fa17d315 feat: global quick entry dialog component 2026-02-20 15:18:34 +02:00
Your Name
cb1c6c9b5d feat: timesheet row persistence and copy last week 2026-02-20 15:17:01 +02:00
Your Name
a3ea37baa1 feat: timesheet row persistence backend 2026-02-20 15:15:50 +02:00
Your Name
54f75c15ed feat: entry template management in settings 2026-02-20 15:10:48 +02:00
Your Name
14c45c67e5 feat: entry template picker and save-as-template in entries view 2026-02-20 15:09:37 +02:00
Your Name
15c8db6572 feat: entry templates pinia store 2026-02-20 15:07:18 +02:00
Your Name
dcfcdaf1b0 feat: entry templates CRUD backend 2026-02-20 15:06:50 +02:00
Your Name
6e05ddcf89 feat: cascade delete dialog for clients with dependency counts 2026-02-20 15:02:39 +02:00
Your Name
529461f12c feat: client cascade delete with dependency counts 2026-02-20 15:01:33 +02:00
Your Name
fb38d98612 feat: smart timer safety net - save dialog on stop without project 2026-02-20 14:58:02 +02:00
Your Name
115bdd33db feat: timer save dialog for no-project and long-timer scenarios 2026-02-20 14:56:17 +02:00
Your Name
4589fea5ce feat: use batch save for invoice items 2026-02-20 14:55:17 +02:00
Your Name
522efbf230 feat: batch invoice items save with transaction 2026-02-20 14:54:37 +02:00
Your Name
43bd3b9b41 fix: independent try/catch per onboarding detection call 2026-02-20 14:47:26 +02:00
Your Name
35e97cbe7b feat: standardize error handling across all stores 2026-02-20 14:46:56 +02:00
Your Name
25c6c55eb2 feat: use unified error handler in entries store 2026-02-20 14:43:10 +02:00
Your Name
1f21cd61c3 feat: unified error handler with retry for transient errors 2026-02-20 14:42:30 +02:00
Your Name
3968a818c5 feat: persistent notifications toggle in settings 2026-02-20 14:40:50 +02:00
Your Name
85b39e41f6 feat: toast undo button and hover/focus pause 2026-02-20 14:38:34 +02:00
Your Name
24b3caf0da feat: toast auto-dismiss with undo and pause support 2026-02-20 14:38:08 +02:00
Your Name
6ed462853c docs: enhancement round 2 implementation plan - 34 tasks
Phase 1: Toast auto-dismiss/undo, unified error handling, onboarding
resilience, invoice batch save, smart timer safety net.
Phase 2: Client cascade delete, entry templates, timesheet persistence,
global quick entry, receipt management.
Phase 3: Dashboard comparison, project health, heatmap, rounding
visibility, complete export with auto-backup.
2026-02-20 14:29:25 +02:00
Your Name
dea742707f docs: enhancement round 2 design - 15 feature proposals
Covers smart timer safety net, toast undo system, unified error
handling, onboarding resilience, invoice save reliability, global
quick entry, entry templates, timesheet persistence, client cascade,
receipt management, weekly comparison, project health cards, time
heatmap, rounding preview, and export scheduling. All features
designed for WCAG 2.2 AAA compliance.
2026-02-20 14:22:01 +02:00
Your Name
a3c0d43f67 feat: add tour store for guided walkthrough state 2026-02-20 09:36:26 +02:00
Your Name
78026c8bf0 chore: tidy up project structure and normalize formatting 2026-02-19 22:43:14 +02:00
Your Name
b8239c6e1b fix: mini timer renders via window label instead of hash routing
The mini timer window was blank because hash-based routing
(createWebHashHistory) doesn't work with Tauri's WebviewUrl path.
Now App.vue detects the mini timer by checking getCurrentWindow().label
=== 'mini-timer' and renders the MiniTimer component directly,
bypassing the router entirely.
2026-02-18 15:26:44 +02:00
Your Name
4462d832d2 fix: mini timer window blank due to hash routing mismatch
The app uses createWebHashHistory but the mini timer window was
opened with WebviewUrl::App("/mini-timer") which sets the URL path,
not the hash fragment. Vue Router never matched the route, so the
Dashboard rendered in a 300x64 window (appearing blank). Now loads
root URL and sets window.location.hash via eval. Also shows/focuses
the main window when closing the mini timer.
2026-02-18 15:23:20 +02:00
Your Name
edccc12c34 feat: load invoice templates from JSON files via backend
Templates are now loaded dynamically from data/templates/*.json
via the get_invoice_templates Tauri command instead of being
hardcoded in TypeScript. Preview and PDF renderer switch on
template.layout instead of template.id, allowing custom templates
to reuse built-in layouts with different colors.
2026-02-18 15:17:54 +02:00
Your Name
5680194ef4 feat: load invoice templates from JSON files in data/templates directory 2026-02-18 15:12:30 +02:00
Your Name
08d61b40a0 fix: delete invoice_items before invoice to prevent FK constraint failure 2026-02-18 15:07:43 +02:00
Your Name
6e00b8b8a3 fix: make template picker full-screen with fixed positioning so buttons are visible 2026-02-18 15:05:02 +02:00
Your Name
f46424141d feat: rewrite InvoicePreview with 15 unique typographic layouts 2026-02-18 14:50:49 +02:00
Your Name
fd4cc29d53 feat: rewrite PDF renderer with 15 unique typographic layouts 2026-02-18 14:45:38 +02:00
Your Name
f40cc97668 feat: add two-step invoice flow with full-screen template picker 2026-02-18 14:43:55 +02:00
Your Name
a313477cd7 feat: update invoicePdf wrapper with new default template ID 2026-02-18 14:41:23 +02:00
Your Name
c5380568ca feat: rewrite invoice template configs with design-doc IDs and colors 2026-02-18 14:39:01 +02:00
Your Name
886a2b100e feat: add template_id to Invoice interface and updateInvoiceTemplate action 2026-02-18 14:38:14 +02:00
Your Name
cca06e851b feat: add template_id column to invoices table and update_invoice_template command 2026-02-18 14:37:26 +02:00
Your Name
9e9c0c78f1 docs: add invoice templates v2 implementation plan 2026-02-18 14:32:38 +02:00
Your Name
5e47700c93 docs: add invoice templates v2 complete redesign design doc 2026-02-18 14:28:41 +02:00
Your Name
162acccc2c feat: integrate template picker into invoice create and preview views 2026-02-18 13:35:11 +02:00
Your Name
06dc063125 feat: add business identity settings for invoice branding 2026-02-18 13:34:44 +02:00
Your Name
3cadb42f8b feat: add InvoicePreview.vue with all 7 header styles and 5 table styles 2026-02-18 13:30:27 +02:00
Your Name
673da2aab8 feat: add InvoiceTemplatePicker split-pane component 2026-02-18 13:28:40 +02:00
Your Name
3928904c40 feat: add config-driven jsPDF invoice renderer with all header and table styles 2026-02-18 13:26:11 +02:00
Your Name
f1a5428dd5 feat: add 15 invoice template configs and registry 2026-02-18 13:16:36 +02:00
Your Name
50734dee03 docs: add invoice templates implementation plan
9-task plan covering template config types, jsPDF renderer,
HTML preview component, template picker UI, Invoices.vue
integration, business identity settings, and polish passes.
2026-02-18 13:12:37 +02:00
Your Name
a527a5ceca docs: add invoice templates design document
15 visually distinct templates across 4 tiers (Professional
Essentials, Creative & Modern, Warm & Distinctive, Premium &
Specialized) with template config schema, picker UI design,
shared renderer architecture, and business identity support.
2026-02-18 13:07:39 +02:00
Your Name
32ee6284da refactor: migrate remaining dialogs to Vue Transition, remove old keyframes
Convert Settings, Invoices, IdlePrompt, AppTrackingPrompt, and
AppDiscard dialogs from animate-modal-enter CSS class to proper
<Transition name="modal"> wrappers for enter/leave animations.
Remove unused animate-modal-enter and animate-dropdown-enter keyframes.
2026-02-18 11:36:35 +02:00
Your Name
04d4220604 feat: add transitions and micro-interactions across all views
- Page transitions with slide-up/fade on route changes (App.vue)
- NavRail sliding active indicator with spring-like easing
- List enter/leave/move animations on Entries, Projects, Clients, Timer
- Modal enter/leave transitions with scale+fade on all dialogs
- Dropdown transitions with overshoot on all select/picker components
- Button feedback (scale on hover/active), card hover lift effects
- Timer pulse on start, glow on stop, floating empty state icons
- Content fade-in on Dashboard, Reports, Calendar, Timesheet
- Tag chip enter/leave animations in AppTagInput
- Progress bar smooth width transitions
- Implementation plan document
2026-02-18 11:33:58 +02:00
Your Name
bd0dbaf91d feat: add animation CSS classes, keyframes, and reduced-motion support 2026-02-18 11:22:32 +02:00
Your Name
5630751adc feat: install @vueuse/motion and create spring presets 2026-02-18 11:19:52 +02:00
Your Name
6b7dcc7317 docs: add motion system design for animations and micro-interactions 2026-02-18 11:07:57 +02:00
Your Name
c7b9822e48 feat: add daily/weekly goals, streaks, and time rounding
Settings Timer tab now has daily/weekly goal hour inputs. Dashboard
shows goal progress bars and streak counter. Settings Billing tab
has rounding toggle with increment and method selectors. New
rounding.ts utility for nearest/up/down time rounding.
2026-02-18 10:51:56 +02:00
Your Name
87b1853f39 feat: add budget progress indicators to Projects and Dashboard
Project edit dialog includes budget hours and amount fields. Project
cards show progress bars with color-coded status. Dashboard displays
budget alerts section for projects exceeding 75% of budget.
2026-02-18 10:51:47 +02:00
Your Name
787f8bbacf feat: integrate tags in Timer and Entries views
Timer shows tag selector below description, saves tags on stop.
Entries table displays tag chips per row with color coding.
Tags loaded from store on mount.
2026-02-18 10:51:39 +02:00
Your Name
7e7e04e4d4 feat: add data import from CSV and JSON
Import utility with CSV parser, Toggl/Clockify format mapping, and
generic CSV column mapping. Settings Data tab has import UI with
file picker, format selector, preview table, and import execution.
2026-02-18 10:46:33 +02:00
Your Name
f6955d1bd7 feat: enhance floating mini timer with controls and pop-out button
MiniTimer shows project color dot, name, elapsed time, stop button,
and expand-to-main button. Timer.vue has pop-out button when running.
2026-02-18 10:46:25 +02:00
Your Name
af95a53c4e feat: add global keyboard shortcuts for timer toggle and show app
Register CmdOrCtrl+Shift+T (toggle timer) and CmdOrCtrl+Shift+Z
(show app) via tauri-plugin-global-shortcut. Shortcut keys are
configurable in Settings Timer tab. Shortcuts re-register on change.
2026-02-18 10:46:18 +02:00
Your Name
e143b069db feat: add profitability tab and favorites strip
Reports view now has Hours/Profitability tabs with per-project revenue
table. Timer view shows favorites strip for quick project selection
and a Save as Favorite button next to the description input.
2026-02-18 10:46:10 +02:00
Your Name
28d199bddc feat: add Calendar, Timesheet, and MiniTimer views
Calendar shows weekly time-block layout with hour rows, entry positioning,
current time indicator, and week navigation. Timesheet provides a weekly
grid with project/task rows, day columns, totals, and add-row functionality.
MiniTimer is a minimal always-on-top timer display for the floating window.
2026-02-18 10:39:08 +02:00
Your Name
b650e981fc feat: add AppTagInput multi-select tag component 2026-02-18 10:35:18 +02:00
Your Name
32d22bf877 feat: add markdown rendering for entry descriptions 2026-02-18 10:35:12 +02:00
Your Name
ba185a1ac9 feat: add duplicate, copy previous day/week, and repeat entry 2026-02-18 10:35:06 +02:00
Your Name
318570295f feat: add theme customization with accent colors and light mode 2026-02-18 10:34:59 +02:00
Your Name
2ddd2ce5d8 feat: add global-shortcut plugin and mini timer window commands 2026-02-18 02:06:07 +02:00
Your Name
f0885921ae feat: add goals, profitability, timesheet, and import commands 2026-02-18 02:04:10 +02:00
Your Name
1ee4562647 feat: add favorites table, CRUD commands, and Pinia store 2026-02-18 02:02:57 +02:00
Your Name
85c20247f5 feat: add project budgets and rounding override columns 2026-02-18 02:02:13 +02:00
Your Name
26f1b19dde feat: add tags table, CRUD commands, and Pinia store 2026-02-18 02:01:04 +02:00
Your Name
afa8bce2c9 fix: dynamic currency symbols and integrated datetime picker
- Replace all hardcoded prefix="$" with :prefix="getCurrencySymbol()"
  in Settings, Projects, and Invoices views
- Replace hardcoded ($) labels with dynamic currency symbol
- Extend AppDatePicker with showTime prop + hour/minute v-models
  for integrated date+time selection
- Simplify Entries.vue to use single AppDatePicker with showTime
  instead of separate hour/minute inputs
2026-02-17 23:53:45 +02:00
Your Name
5ad901ca4f fix: add viewport margin to all modal dialogs 2026-02-17 23:41:59 +02:00
Your Name
137be610f8 feat: replace native datetime-local with custom date picker + time inputs 2026-02-17 23:41:24 +02:00
Your Name
519bdabe61 feat: replace all hardcoded en-US and $ formatting with locale-aware helpers 2026-02-17 23:39:31 +02:00
Your Name
fe0b20f247 feat: replace native number inputs with AppNumberInput across all views 2026-02-17 23:36:02 +02:00
Your Name
8112fe8fd6 feat: add locale and currency settings with searchable dropdowns 2026-02-17 23:35:27 +02:00
Your Name
a13dff96c8 fix: apply default hourly rate from settings when creating new projects 2026-02-17 23:35:24 +02:00
Your Name
7d43f02e59 feat: add AppNumberInput component with press-and-hold repeat 2026-02-17 23:33:13 +02:00
Your Name
06d646c8de feat: add searchable prop to AppSelect for filtering long option lists 2026-02-17 23:33:11 +02:00
Your Name
7fed47e54f feat: add comprehensive locale utility with 140+ locales and 120+ currencies 2026-02-17 23:31:04 +02:00
Your Name
21f762edd9 docs: add UI improvements batch implementation plan 2026-02-17 23:22:40 +02:00
Your Name
93bc6713b4 docs: add UI improvements batch design (locale, datetime picker, number input, etc.) 2026-02-17 23:17:00 +02:00
Your Name
e3f7e2f470 feat: add Clients view with card grid, dialogs, and billing details 2026-02-17 22:57:08 +02:00
Your Name
c949a08981 feat: add Client billing fields to store, /clients route, and reorder NavRail 2026-02-17 22:54:31 +02:00
Your Name
5ab96769ac feat: add client billing fields to database and Rust backend 2026-02-17 22:52:51 +02:00
Your Name
29a0510192 docs: add Clients view and NavRail reorg implementation plan 2026-02-17 22:48:22 +02:00
Your Name
0901673c28 docs: add Clients view and NavRail reorg design 2026-02-17 22:44:50 +02:00
Your Name
3dbe4b4ac8 fix: make custom dropdowns and date pickers respect UI zoom setting
Teleported panels read zoom from #app and apply it to their own style,
with position coordinates divided by the zoom factor so they align
correctly with the zoomed trigger elements.
2026-02-17 22:35:42 +02:00
Your Name
40f87c9e04 feat: replace all native selects and date inputs with custom components 2026-02-17 22:27:51 +02:00
Your Name
b9aace912b feat: add AppDatePicker custom calendar component 2026-02-17 22:24:47 +02:00
Your Name
8cdd30b9e4 feat: add AppSelect custom dropdown component 2026-02-17 22:22:43 +02:00
Your Name
c8a6fd294e docs: add custom dropdowns and date pickers implementation plan 2026-02-17 22:17:42 +02:00
Your Name
ea9f374568 docs: add custom dropdowns and date pickers design 2026-02-17 22:15:10 +02:00
Your Name
a300d85f6f feat: upgrade typography - Plus Jakarta Sans headings, JetBrains Mono data, 14px base
Heading font: Plus Jakarta Sans (500/600/700) for all h1-h3, stat values, dialog titles, timer display, and wordmark.
Body font: Inter (400/500/600/700) unchanged but base bumped from 13px to 14px.
Mono font: JetBrains Mono replaces IBM Plex Mono for code and tabular data.
2026-02-17 22:06:48 +02:00
Your Name
3c8868c899 style: bump border-radius globally - rounded to rounded-lg, rounded-lg to rounded-xl 2026-02-17 21:56:48 +02:00
Your Name
a8bec56d96 feat: redesign Settings with left sidebar tabs per Apple HIG
Four tabs (General, Timer, Billing, Data) with icon + label sidebar,
amber active indicator, auto-save on change, progressive disclosure
for timer settings, and danger zone isolated within Data tab.
2026-02-17 21:49:48 +02:00
Your Name
71d3d9ba8b docs: add Settings sidebar tabs design 2026-02-17 21:48:28 +02:00
Your Name
f9b87cc41c refactor: reorganize Settings per Apple HIG - auto-save, progressive disclosure, danger zone 2026-02-17 21:43:04 +02:00
Your Name
59bfc9fa5a fix: window dragging - use startDragging() API instead of data attribute 2026-02-17 21:36:30 +02:00
Your Name
eab5e94452 feat: persist window position and size between runs 2026-02-17 21:33:32 +02:00
Your Name
fc43d2bc29 feat: portable storage - data directory next to exe 2026-02-17 21:33:26 +02:00
Your Name
8264082719 feat: zoom initialization and toast container in App.vue 2026-02-17 21:32:15 +02:00
Your Name
28088b9566 feat: redesign Settings - amber save, UI zoom, toasts 2026-02-17 21:31:54 +02:00
Your Name
644f9ee3ce feat: redesign Invoices - amber tabs and totals, rich empty state 2026-02-17 21:31:05 +02:00
Your Name
f70ba8bc31 feat: redesign Reports - amber actions and stats, toast notifications 2026-02-17 21:29:53 +02:00
Your Name
6082e75734 feat: redesign Entries - filter container, amber actions, rich empty state 2026-02-17 21:28:42 +02:00
Your Name
919fb5f499 feat: redesign Projects - amber button, color presets, rich empty state 2026-02-17 21:27:49 +02:00
Your Name
d1fd1c9ea8 feat: redesign Timer - amber Start, colon pulse, toast 2026-02-17 21:26:33 +02:00
Your Name
30eeb15ece feat: redesign Dashboard - greeting, amber stats, rich empty state 2026-02-17 21:25:52 +02:00
Your Name
9f79309ada feat: amber wordmark and NavRail active indicator 2026-02-17 21:25:08 +02:00
Your Name
520846511b feat: add toast notification system 2026-02-17 21:25:02 +02:00
Your Name
552e8c0607 feat: overhaul design tokens - charcoal palette + amber accent 2026-02-17 21:24:15 +02:00
163 changed files with 40726 additions and 4029 deletions

View File

@@ -1,11 +0,0 @@
{
"permissions": {
"allow": [
"WebSearch",
"mcp__searxng__searxng_web_search",
"Bash(git init:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
]
}
}

39
.gitignore vendored
View File

@@ -1 +1,40 @@
node_modules node_modules
dist
docs
trash
# Rust/Tauri build artifacts
src-tauri/target
src-tauri/gen
# AI/LLM tools
.claude
.claude/*
CLAUDE.md
.cursorrules
.cursor
.cursor/
.copilot
.copilot/
.github/copilot
.aider*
.aiderignore
.continue
.continue/
.ai
.ai/
.llm
.llm/
.windsurf
.windsurf/
.codeium
.codeium/
.tabnine
.tabnine/
.sourcery
.sourcery/
cursor.rules
.bolt
.bolt/
.v0
.v0/

116
LICENSE Normal file
View File

@@ -0,0 +1,116 @@
CC0 1.0 Universal
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator and
subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for the
purpose of contributing to a commons of creative, cultural and scientific
works ("Commons") that the public can reliably and without fear of later
claims of infringement build upon, modify, incorporate in other works, reuse
and redistribute as freely as possible in any form whatsoever and for any
purposes, including without limitation commercial purposes. These owners may
contribute to the Commons to promote the ideal of a free culture and the
further production of creative, cultural and scientific works, or to gain
reputation or greater distribution for their Work in part through the use and
efforts of others.
For these and/or other purposes and motivations, and without any expectation
of additional consideration or compensation, the person associating CC0 with a
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
and publicly distribute the Work under its terms, with knowledge of his or her
Copyright and Related Rights in the Work and the meaning and intended legal
effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not limited
to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
and Related Rights and associated claims and causes of action, whether now
known or unknown (including existing as well as future claims and causes of
action), in the Work (i) in all territories worldwide, (ii) for the maximum
duration provided by applicable law or treaty (including future time
extensions), (iii) in any current or future medium and for any number of
copies, and (iv) for any purpose whatsoever, including without limitation
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
the Waiver for the benefit of each member of the public at large and to the
detriment of Affirmer's heirs and successors, fully intending that such Waiver
shall not be subject to revocation, rescission, cancellation, termination, or
any other legal or equitable action to disrupt the quiet enjoyment of the Work
by the public as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason be
judged legally invalid or ineffective under applicable law, then the Waiver
shall be preserved to the maximum extent permitted taking into account
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
is so judged Affirmer hereby grants to each affected person a royalty-free,
non transferable, non sublicensable, non exclusive, irrevocable and
unconditional license to exercise Affirmer's Copyright and Related Rights in
the Work (i) in all territories worldwide, (ii) for the maximum duration
provided by applicable law or treaty (including future time extensions), (iii)
in any current or future medium and for any number of copies, and (iv) for any
purpose whatsoever, including without limitation commercial, advertising or
promotional purposes (the "License"). The License shall be deemed effective as
of the date CC0 was applied by Affirmer to the Work. Should any part of the
License for any reason be judged legally invalid or ineffective under
applicable law, such partial invalidity or ineffectiveness shall not invalidate
the remainder of the License, and in such case Affirmer hereby affirms that he
or she will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of action
with respect to the Work, in either case contrary to Affirmer's express
Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness
for a particular purpose, non infringement, or the absence of latent or
other defects, accuracy, or the present or absence of errors, whether or not
discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without limitation
any person's Copyright and Related Rights in the Work. Further, Affirmer
disclaims responsibility for obtaining any necessary consents, permissions or
other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this
CC0 or use of the Work.
For more information, please see
<http://creativecommons.org/publicdomain/zero/1.0/>

264
README.md Normal file
View File

@@ -0,0 +1,264 @@
<div align="center">
# &#9203; ZeroClock
**Your time. Your data. Your rules.**
A local-first time tracker for freelancers, artists, and anyone who believes the value of their labor belongs to them - not a cloud platform.
![Tauri](https://img.shields.io/badge/Tauri_v2-24C8D8?style=for-the-badge&logo=tauri&logoColor=white)
![Vue](https://img.shields.io/badge/Vue_3-4FC08D?style=for-the-badge&logo=vuedotjs&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white)
![Rust](https://img.shields.io/badge/Rust-000000?style=for-the-badge&logo=rust&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-003B57?style=for-the-badge&logo=sqlite&logoColor=white)
![WCAG AAA](https://img.shields.io/badge/WCAG_2.2-AAA-228B22?style=for-the-badge)
![No Cloud](https://img.shields.io/badge/100%25_Local-No_Cloud-8B0000?style=for-the-badge)
![No Telemetry](https://img.shields.io/badge/Telemetry-None-4B0082?style=for-the-badge)
![License](https://img.shields.io/badge/License-CC0_1.0-blue?style=for-the-badge)
*No subscriptions. No surveillance. No corporate middleman between you and your work.*
</div>
---
## What is ZeroClock?
ZeroClock is a desktop time tracker that runs entirely on your machine. Every second you track, every invoice you generate, every report you pull - it all lives in a single SQLite database on your own hard drive. No accounts. No sign-ups. No data harvested. No monthly rent for the privilege of knowing how you spend your own time.
It was built for the people who do the work: freelancers juggling five clients, illustrators billing by the hour, developers who want transparency in how their days are spent, and small collectives who refuse to feed their labor data into someone else's growth metrics.
---
## 🎯 Core features
### &#9202; Timer
A big, readable timer front and center. Start it, pause it, stop it. Switch projects mid-session without losing a second. The two-column layout keeps the timer always visible while you scroll through recent sessions.
- **Start / Stop / Pause / Resume** with a single click or a global keyboard shortcut
- **Quick-switch projects** mid-session without stopping the timer
- **Idle detection** - ZeroClock notices when you step away and asks what to do with that time
- **App tracking** - optionally detect which applications are running and associate time with projects
- **Favorites** - save project presets as reorderable chips for one-click tracking
- **Mini timer** - a tiny, always-on-top floating window so the timer is never out of sight
### 📋 Entries
Every tracked session becomes an entry you fully control.
- **Filter and search** by project, client, date range, billable status, or tags
- **Bulk operations** - select multiple entries to change projects, toggle billable, or delete
- **Split entries** - break a long session into multiple entries when you forgot to switch
- **Duplicate entries** - repeat yesterday's work with one click
- **Bulk add** - create several entries at once for retroactive logging
- **Entry templates** - save and reuse common entry patterns
- **Pagination** with smooth loading for thousands of entries
### 👥 Clients and projects
Organize your work however makes sense for you - not how a product manager decided you should.
- **Clients** with name, email, company, and address
- **Projects** with hourly rates, budgets, color coding, and task breakdowns
- **Cascade awareness** - before deleting a client or project, see exactly what depends on it
- **Archive projects** without losing data
- **Group projects by client** or view them flat
### 📊 Reports
Understand where your time goes. Spot the clients who drain you. Find the projects that sustain you.
- **Hours breakdown** - bar charts, tables, daily/weekly/monthly summaries
- **Profitability analysis** - compare hours tracked against budgets, see who pays fairly
- **Expense reports** - track costs alongside time for a complete picture
- **Work patterns** - heatmaps and weekday distributions revealing your natural rhythms
- **Export to CSV or PDF** for sharing with clients or keeping your own records
### 🧾 Invoicing
Generate invoices from your tracked time without leaving the app.
- **Multiple templates** - choose from several professional invoice designs
- **Invoice pipeline** - track invoices through Draft, Sent, Paid, and Overdue stages
- **Automatic line items** from tracked time entries
- **Recurring invoices** - set up repeating invoices on a schedule
- **Payment tracking** - record partial or full payments against invoices
- **Overdue detection** with badge notifications
- **PDF export** - download polished invoices ready to send
- **Business identity** - your name, address, and logo on every invoice
### 💰 Expenses
Time is not the only thing worth tracking.
- **Log expenses** against projects with amounts, dates, and categories
- **Receipt attachments** with a built-in lightbox viewer
- **Link to invoices** - mark expenses as invoiced so nothing slips through
- **Filter by date range** with quick presets (this week, last month, this quarter)
---
## 📅 Views
### Calendar
See your tracked time laid out in a familiar day / week / month calendar grid. Import `.ics` files from external calendars to see everything in one place. Click any slot to create an entry.
### Timesheet
A weekly grid for structured time entry, row by row. Lock completed weeks to prevent accidental edits. Copy last week's structure when your schedule repeats. Add rows for new project-task combinations and fill in hours per day.
### Dashboard
A bird's-eye view of your work: today's hours, weekly progress toward goals, recent entries with one-click replay, and a getting-started checklist for new users.
---
## 🔧 Customization
ZeroClock adapts to you. Not the other way around.
| Setting | Options |
|---------|---------|
| Theme | Dark, Light, or follow your OS |
| Accent color | Amber, Blue, Green, Rose, Purple, Teal |
| UI font | 15+ Google Fonts with live preview |
| Timer font | 16 curated monospace fonts |
| UI scale | 80% to 150% zoom |
| Sound effects | Configurable events, volume, and synthesis mode |
| Reduce motion | System, Always, or Never |
| Dyslexia-friendly mode | OpenDyslexic font throughout the interface |
### System tray
- **Close to tray** - the window disappears but ZeroClock keeps running silently
- **Minimize to tray** - minimize hides to the system tray instead of the taskbar
- Left-click the tray icon to bring the window back
### Keyboard shortcuts
- **Global hotkeys** - toggle the timer or show the app from anywhere on your desktop
- **In-app shortcuts** - navigate, start/stop, and manage entries without touching the mouse
- **Customizable** - remap shortcuts to whatever feels natural
---
## 💾 Data ownership
This is the part that matters. Your data never leaves your machine.
- **Single SQLite file** - your entire history in one portable database
- **Auto-backup** - scheduled backups to a folder you choose, with configurable frequency and retention
- **Manual export** - full JSON export of every table, every entry, every setting
- **CSV export** - pull reports into a spreadsheet whenever you need to
- **JSON import** - bring data in from other tools
- **No cloud sync** - because "we got hacked" emails should not be part of your time-tracking experience
- **No accounts** - nothing to delete because nothing was ever collected
Your labor, your records, your hard drive.
---
## ♿ Accessibility
ZeroClock is built to be usable by everyone. Not as an afterthought, but as a foundation.
### WCAG 2.2 AAA compliance
- **7:1 contrast ratios** on all text throughout the interface
- **Focus indicators** visible on every interactive element
- **Semantic HTML** with proper landmarks, headings, and ARIA roles
- **Screen reader support** - live regions announce timer state changes, meaningful labels on every control
- **Tooltips on every button** - hover or focus any icon button for a description of what it does, with proper `aria-describedby` linking and Escape to dismiss
- **Keyboard navigation** - every feature is reachable without a mouse
- **Reduce motion** - respect your OS preference or override it manually
- **Dyslexia-friendly mode** - switch the entire interface to OpenDyslexic with one toggle
- **UI scaling** - zoom the interface from 80% to 150% without breaking layouts
Accessibility is not a feature. It is a baseline.
---
## 🚀 Getting started
### Prerequisites
- [Node.js](https://nodejs.org/) 18+
- [Rust](https://rustup.rs/) (latest stable)
- Platform-specific Tauri dependencies - see the [Tauri prerequisites guide](https://v2.tauri.app/start/prerequisites/)
### Build and run
```bash
# Clone the repository
git clone https://github.com/your-username/zeroclock.git
cd zeroclock
# Install frontend dependencies
npm install
# Run in development mode
npx tauri dev
# Build for production
npx tauri build
```
The database is created automatically on first launch in the same directory as the executable.
---
## 🏗️ Architecture
```
zeroclock/
src/ # Vue 3 frontend
components/ # Reusable UI components
composables/ # Shared composition functions
directives/ # Vue directives (tooltips, etc.)
stores/ # Pinia state management
utils/ # Helpers, formatters, audio
views/ # Page-level components
styles/ # Tailwind CSS and theme variables
src-tauri/ # Rust backend
src/
commands.rs # Tauri IPC command handlers
database.rs # SQLite schema and migrations
lib.rs # App setup, tray, plugins
seed.rs # Sample data generator
```
The frontend and backend communicate through Tauri's IPC bridge. The frontend never touches the filesystem directly - all data flows through typed Rust commands that validate, query, and persist to SQLite.
---
## 🤝 Contributing
ZeroClock is built in the open. If you find it useful, you are welcome to help make it better.
- **Report bugs** by opening an issue
- **Suggest features** - especially if they help workers track and own their time more effectively
- **Submit patches** - fork, branch, and open a pull request
- **Improve accessibility** - if something does not work with your assistive technology, that is a bug
Good-faith contributions from people who care about the work are always welcome.
---
## 📝 License
ZeroClock is released under [CC0 1.0 Universal](LICENSE) - dedicated to the public domain. No permissions needed. No conditions. No strings. Take it, use it, change it, sell it, give it away. The work belongs to everyone.
---
<div align="center">
*Built for the people who do the work.*
*No venture capital. No growth metrics. No exit strategy.*
*Just a tool that respects your time.*
</div>

View File

@@ -1,217 +0,0 @@
# Local Time Tracker - Design Document
**Date:** 2026-02-17
**Status:** Approved
---
## 1. Overview
A portable desktop time tracking application for freelancers and small teams. Replaces cloud-based services like Toggl Track, Harvest, and Clockify with a fully local-first solution that stores all data next to the executable.
**Target Users:** Freelancers, small teams (2-10), independent contractors
**Platform:** Windows (Tauri v2 + Vue 3)
---
## 2. Architecture
### Tech Stack
- **Framework:** Tauri v2 (Rust backend)
- **Frontend:** Vue 3 + TypeScript + Vite
- **UI Library:** shadcn-vue v2.4.3 + Tailwind CSS v4
- **State Management:** Pinia
- **Database:** SQLite (rusqlite)
- **Charts:** Chart.js
- **PDF Generation:** jsPDF
- **Icons:** Lucide Vue
### Data Storage (Portable)
All data stored in `./data/` folder next to the executable:
- `./data/timetracker.db` - SQLite database
- `./data/exports/` - CSV and PDF exports
- `./data/logs/` - Application logs
- `./data/config.json` - User preferences
**No registry, no AppData, no cloud dependencies.**
---
## 3. UI/UX Design
### Window Model
- **Main Window:** Frameless with custom title bar (1200x800 default, resizable, min 800x600)
- **Title Bar:** Integrated menu + window controls (minimize, maximize, close)
- **Timer Bar:** Always visible below title bar
### Layout Structure
```
┌─────────────────────────────────────────────────────────┐
│ [Logo] LocalTimeTracker [File Edit View Help] [─][□][×] │ ← Custom Title Bar
├─────────────────────────────────────────────────────────┤
│ [▶ START] 00:00:00 [Project ▼] [Task ▼] │ ← Timer Bar
├────────────┬────────────────────────────────────────────┤
│ │ │
│ Dashboard │ Main Content Area │
│ Timer │ │
│ Projects │ - Dashboard: Overview charts │
│ Entries │ - Timer: Active timer view │
│ Reports │ - Projects: Project/client list │
│ Invoices │ - Entries: Time entry table │
│ Settings │ - Reports: Charts and summaries │
│ │ - Invoices: Invoice builder │
│ │ - Settings: Preferences │
│ │ │
└────────────┴────────────────────────────────────────────┘
```
### Visual Design
**Color Palette (Dark Mode + Amber):**
| Role | Color | Usage |
|------|-------|-------|
| Background | `#0F0F0F` | Page background |
| Surface | `#1A1A1A` | Cards, panels |
| Surface Elevated | `#242424` | Hover states, modals |
| Border | `#2E2E2E` | Subtle separation |
| Text Primary | `#FFFFFF` (87%) | Headings, body |
| Text Secondary | `#A0A0A0` (60%) | Labels, hints |
| Accent (Amber) | `#F59E0B` | Primary actions, active states |
| Accent Hover | `#D97706` | Button hover |
| Accent Light | `#FCD34D` | Highlights |
| Success | `#22C55E` | Positive status |
| Warning | `#F59E0B` | Warnings |
| Error | `#EF4444` | Errors |
**Typography:**
- **Headings/Body:** IBM Plex Sans
- **Timer/Data:** IBM Plex Mono
- **Scale:** 1.250 (Major Third)
**Spacing:**
- Base unit: 4px
- Comfortable density (16px standard padding)
**Border Radius:** 8px (cards, buttons, inputs)
### Components
**Navigation:**
- Sidebar (220px fixed)
- Items: Dashboard, Timer, Projects, Entries, Reports, Invoices, Settings
- Active state: Amber highlight + left border accent
**Timer Bar:**
- Start/Stop button (amber when active)
- Running time display (mono font, large)
- Project selector dropdown
- Task selector dropdown
**Buttons:**
- Primary: Amber fill
- Secondary: Outlined
- Ghost: Text only
**Cards:**
- Dark surface (`#1A1A1A`)
- Subtle border (`#2E2E2E`)
- Rounded corners (8px)
**Forms:**
- Dark background
- Amber focus ring
---
## 4. Functional Requirements
### 4.1 Timer
- One-click start/stop timer
- Project and task assignment
- Optional notes/description
- Manual time entry for forgotten sessions
- Idle detection with prompt to keep/discard idle time
- Reminder notifications
### 4.2 Projects & Clients
- Create/edit/delete projects
- Group projects by client
- Set hourly rate per project
- Archive projects
### 4.3 Time Entries
- List all time entries with filtering
- Edit existing entries
- Delete entries
- Bulk actions (delete, export)
### 4.4 Reports
- Weekly/monthly summaries
- Bar charts for time distribution
- Pie charts for project breakdown
- Filter by date range, project, client
- Export to CSV
### 4.5 Invoices
- Generate from tracked time
- Customizable line items
- Client details
- Tax rates, discounts
- Payment terms
- PDF export
### 4.6 Settings
- Theme preferences (dark mode only initially)
- Default hourly rate
- Idle detection settings
- Reminder intervals
- Data export/import
- Clear all data
### 4.7 System Integration
- System tray residence
- Compact floating timer window (optional)
- Global hotkey to start/stop
- Auto-start on login (optional)
- Native notifications
---
## 5. Data Model
### Tables
- `clients` - Client information
- `projects` - Projects linked to clients
- `tasks` - Tasks within projects
- `time_entries` - Individual time entries
- `invoices` - Generated invoices
- `invoice_items` - Line items for invoices
- `settings` - User preferences
---
## 6. Motion & Interactions
**Animation Style:** Moderate/Purposeful (200-300ms transitions)
**Key Interactions:**
- Timer: Subtle amber glow when running
- Cards: Soft lift on hover
- Buttons: Scale/color change on press
- View transitions: Fade + slight slide
- Empty states: Animated illustrations
---
## 7. Acceptance Criteria
1. ✅ App launches without errors
2. ✅ Timer starts/stops and tracks time correctly
3. ✅ Projects and clients can be created/edited/deleted
4. ✅ Time entries are persisted to SQLite
5. ✅ Reports display accurate charts
6. ✅ Invoices generate valid PDFs
7. ✅ All data stored in ./data/ folder (portable)
8. ✅ Custom title bar with working window controls
9. ✅ System tray integration works
10. ✅ Dark mode with amber accent throughout

File diff suppressed because it is too large Load Diff

View File

@@ -1,371 +0,0 @@
# ZeroClock UI Polish & UX Upgrade — Design
## Problem
The first redesign pass established a Swiss/Dieter Rams foundation but went too far into monochrome territory. The app is a wall of near-black grey with zero personality. The signature amber accent color is completely absent. All buttons look identical. Empty states are sad grey text. `alert()` calls for all feedback. No visual hierarchy for primary actions. The app also stores data in AppData (not portable) and doesn't remember window position/size.
## Goals
1. Reintroduce warm amber (#D97706) as the strategic accent color
2. Lift the entire background palette from near-black to charcoal
3. Establish clear button hierarchy (primary/secondary/ghost/danger)
4. Replace all `alert()` calls with a toast notification system
5. Design rich empty states with icons, copy, and CTA buttons
6. Add amber focus states on all inputs
7. Add UI zoom control in Settings (persistent)
8. Make the app fully portable (data next to exe)
9. Persist window position and size between runs
---
## Design System
### Color Palette (Revised)
```
Background layers (lifted from near-black to charcoal):
--bg-base: #1A1A18 app background
--bg-surface: #222220 cards, panels, navRail, titlebar
--bg-elevated: #2C2C28 hover, active, raised elements
--bg-inset: #141413 inputs, recessed areas
Text (warm whites — unchanged):
--text-primary: #F5F5F0 headings, active items
--text-secondary: #8A8A82 body, descriptions
--text-tertiary: #5A5A54 disabled, placeholders (bumped from #4A4A45)
Borders (bumped to match brighter backgrounds):
--border-subtle: #2E2E2A dividers, card borders
--border-visible: #3D3D38 input borders, focused
Accent (NEW — amber):
--accent: #D97706 button fills, active indicators
--accent-hover: #B45309 hover/pressed amber
--accent-muted: rgba(217,119,6,0.12) subtle glows, backgrounds
--accent-text: #FBBF24 amber text on dark backgrounds
Status (semantic — unchanged):
--status-running: #34D399 timer active, success, toggle on
--status-warning: #EAB308 pending, caution
--status-error: #EF4444 destructive, overdue
--status-info: #3B82F6 informational
```
Remove all legacy alias colors (--color-amber mapped to white, --color-background, --color-surface, etc.).
### Button Hierarchy
**Primary** — amber fill, for the ONE main action per view:
- `bg-accent text-[#1A1A18] font-medium rounded`
- Hover: `bg-accent-hover`
- Used: Start timer, Create Project, Save Settings, Generate Report, Apply Filters, Create Invoice
**Secondary** — outlined, for supporting actions:
- `border border-border-visible text-text-primary rounded`
- Hover: `bg-bg-elevated`
- Used: Export CSV, Export Data, Cancel buttons, Clear filters
**Ghost** — text only, for low-priority actions:
- `text-text-secondary hover:text-text-primary`
- No border, no background
- Used: "View all" links, tab navigation inactive
**Danger** — destructive actions:
- `border border-status-error text-status-error rounded`
- Hover: `bg-status-error/10`
- Used: Clear Data, Delete buttons in confirmation dialogs
### Input Focus States
All inputs, selects, and textareas:
- Current: `focus:border-border-visible` (grey, barely visible)
- New: `focus:border-accent focus:outline-none` with `box-shadow: 0 0 0 2px rgba(217,119,6,0.12)` (amber border + subtle amber glow)
---
## Shell
### TitleBar
- "ZEROCLOCK" wordmark: `text-accent-text` (amber #FBBF24) instead of `text-text-secondary`
- Running timer section: unchanged (green dot + project + time + stop)
- Window controls: unchanged
### NavRail
- Active indicator: 2px left border in `bg-accent` (#D97706) instead of `bg-text-primary` (white)
- Active icon color: stays `text-text-primary` (white)
- Tooltip: add subtle caret/triangle pointing left for polish
- Timer dot at bottom: stays green (semantic)
---
## View Designs
### Dashboard
**Header:**
- Greeting: "Good morning/afternoon/evening" in `text-lg text-text-secondary`
- Date: "Monday, February 17, 2026" in `text-xs text-text-tertiary`
**Stats row (4 stats):**
- This Week | This Month | Today | Active Projects
- Labels: `text-xs text-text-tertiary uppercase tracking-[0.08em]`
- Values: `text-[1.25rem] font-mono text-accent-text` (amber)
**Weekly chart:**
- Bar fill: `#D97706` (amber), today's bar: `#FBBF24` (lighter amber)
- Grid: `#2E2E2A`, ticks: `#5A5A54`
**Recent entries:**
- Flat list as current
- "View all" ghost link bottom-right, navigates to Entries
**Empty state:**
- Centered vertically in available space
- Lucide `Clock` icon, 48px, `text-text-tertiary`
- "Start tracking your time" — `text-text-secondary`
- "Your dashboard will come alive with stats, charts, and recent activity once you start logging hours." — `text-xs text-text-tertiary`
- Primary amber button: "Go to Timer"
### Timer
**Hero display:**
- `text-[3rem] font-mono text-text-primary` centered
- When running: colon separators pulse amber (`text-accent-text` with opacity animation)
- When stopped: all white, static
**Start/Stop button:**
- Start: `bg-accent text-[#1A1A18] px-10 py-3 text-sm font-medium rounded`
- Stop: `bg-status-error text-white px-10 py-3 text-sm font-medium rounded` (filled red)
- 150ms color transition between states
**Inputs:**
- `max-w-[36rem] mx-auto` (centered)
- Amber focus states
- Project select: small colored dot preview of selected project color
**Recent entries:**
- Scoped to today's entries
- Most recent entry: subtle amber left border
- Max 5, "View all" ghost link
**Empty state:**
- Lucide `Timer` icon, 40px, `text-text-tertiary`
- "No entries today" — `text-text-secondary`
- "Select a project and hit Start to begin tracking" — `text-xs text-text-tertiary`
### Projects
**Header:**
- Title "Projects" left
- "+ Add" becomes small amber primary button: `bg-accent text-[#1A1A18] px-3 py-1.5 text-xs font-medium rounded`
**Cards:**
- 2px left border in project color
- `bg-bg-surface hover:bg-bg-elevated` transition
- Hover: left border widens from 2px to 3px
- Rate and client inline: "ClientName · $50.00/hr"
**Create/Edit dialog:**
- Submit: amber primary
- Cancel: secondary
- Color picker: row of 8 preset swatches above hex input
- Presets: #D97706, #3B82F6, #8B5CF6, #EC4899, #10B981, #EF4444, #06B6D4, #6B7280
**Empty state:**
- Lucide `FolderKanban` icon, 48px, `text-text-tertiary`
- "No projects yet" — `text-text-secondary`
- "Projects organize your time entries and set billing rates for clients." — `text-xs text-text-tertiary`
- Primary amber button: "Create Project" (opens dialog)
### Entries
**Filter bar:**
- Wrapped in `bg-bg-surface rounded p-4` container
- "Apply": amber primary
- "Clear": ghost button
- `mb-6` gap between filter bar and table
**Table:**
- Header row: `bg-bg-surface` background
- Duration column: `text-accent-text font-mono` (amber)
- Edit/delete hover reveal: unchanged
**Empty state (below filter bar):**
- Lucide `List` icon, 48px, `text-text-tertiary`
- "No entries found" — `text-text-secondary`
- "Time entries will appear here as you track your work. Try adjusting the date range if you have existing entries." — `text-xs text-text-tertiary`
- Primary amber button: "Go to Timer"
### Reports
**Filter bar:**
- Same `bg-bg-surface rounded p-4` container
- "Generate": amber primary
- "Export CSV": secondary
**Stats:**
- `mt-6` spacing after filter bar
- Values: `text-accent-text font-mono` (amber)
**Chart:**
- Bars use each project's own assigned color
- Fallback for no-color projects: #D97706
- Grid: `#2E2E2A`, ticks: `#5A5A54`
**Breakdown:**
- Hours value: `text-accent-text font-mono`
- Earnings: `text-text-secondary font-mono`
**Empty state:**
- Lucide `BarChart3` icon, 48px, `text-text-tertiary`
- "Generate a report to see your data" — `text-text-secondary`
- No CTA button (Generate button is right there)
### Invoices
**Tabs:**
- Active: `border-b-2 border-accent text-text-primary` (amber underline)
- Inactive: `text-text-tertiary hover:text-text-secondary`
**List table:**
- Header row: `bg-bg-surface`
- Amount: `text-accent-text font-mono`
- Status colors: unchanged (semantic)
**Create form:**
- Submit: amber primary
- Cancel: secondary
- Total line: `text-accent-text font-mono`
**Invoice detail dialog:**
- Export PDF: amber primary
- Total: `text-accent-text`
**Empty state:**
- Lucide `FileText` icon, 48px, `text-text-tertiary`
- "No invoices yet" — `text-text-secondary`
- "Create invoices from your tracked time to bill clients." — `text-xs text-text-tertiary`
- Primary amber button: "Create Invoice" (switches to Create tab)
### Settings
**Buttons:**
- "Save Settings": amber primary
- "Export": secondary
- "Clear Data": danger (red)
**Toggle:** stays green when active (semantic "on" state)
**All `alert()` calls:** replaced with toasts
**UI Zoom control (NEW):**
- New section "Appearance" between Timer and Data sections
- Label: "UI Scale"
- Minus button [-] | value display "100%" | Plus button [+]
- Steps: 80%, 90%, 100%, 110%, 120%, 130%, 150%
- Implementation: CSS `zoom` property on the `#app` root element
- Persisted via `update_settings('ui_zoom', '100')` in the settings SQLite table
- Applied on app startup before first paint (read from settings store in App.vue onMounted)
---
## Toast Notification System
### Component: `ToastNotification.vue`
- Fixed position, top-center of the main content area, `top-4`
- Max width 320px
- `bg-bg-surface border border-border-subtle rounded shadow-lg`
- 3px left border colored by type:
- Success: `border-status-running` (green)
- Error: `border-status-error` (red)
- Info: `border-accent` (amber)
- Content: Lucide icon (Check/X/Info, 16px) + message in `text-sm text-text-primary`
- Enter animation: slide down from -20px + fade, 200ms
- Exit: fade out 150ms
- Auto-dismiss: 3 seconds
- Click to dismiss early
- Stack with 8px gap, max 3 visible
### Store: `useToastStore`
- `addToast(message: string, type: 'success' | 'error' | 'info')`
- Auto-generates unique ID
- Auto-removes after 3s timeout
- Max 3 toasts visible, oldest removed first
### Replacements
All `alert()` calls across the app become toast calls:
- Settings save success/failure
- Clear data success/failure
- Export data success/failure
- Timer "Please select a project"
- Reports "Please select a date range"
- Reports "No data to export"
- Invoices "Please select a client"
---
## Portable App
### Problem
Currently uses `directories::ProjectDirs` to store the SQLite database in the OS app data directory (e.g., `C:\Users\<user>\AppData\Roaming\ZeroClock\`). This is not portable.
### Solution
Change `get_data_dir()` in `lib.rs` to always resolve relative to the executable:
```rust
fn get_data_dir() -> PathBuf {
let exe_path = std::env::current_exe().unwrap();
let data_dir = exe_path.parent().unwrap().join("data");
std::fs::create_dir_all(&data_dir).ok();
data_dir
}
```
This stores `data/timetracker.db` next to the `.exe`. The `directories` crate dependency can be removed from Cargo.toml.
---
## Window State Persistence
### Plugin: `tauri-plugin-window-state`
Add the Tauri window-state plugin to save/restore:
- Window position (x, y)
- Window size (width, height)
- Maximized state
### Implementation
1. Add dependency: `tauri-plugin-window-state = "2"` to Cargo.toml
2. Add `"window-state"` to plugins in tauri.conf.json
3. Register plugin: `.plugin(tauri_plugin_window_state::Builder::new().build())` in lib.rs
4. The plugin auto-saves state to a `.window-state` file. Since we're making the app portable, configure the plugin to store state in our `data/` directory next to the exe.
### Config
In `tauri.conf.json`, add to plugins:
```json
"window-state": {
"all": true
}
```
Remove `"center": true` from the window config so the saved position is respected on subsequent launches.
---
## Transitions & Motion
Unchanged from first redesign, plus:
- Toast enter: translateY(-20px) + opacity 0 to 0+1, 200ms ease-out
- Toast exit: opacity 1 to 0, 150ms
- Button hover: 150ms background-color transition
- Project card left-border width: 150ms transition on hover
- Timer colon amber pulse: opacity 0.4 to 1.0 on accent-text color, 1s ease-in-out infinite (only when running)

View File

@@ -1,652 +0,0 @@
# UI Polish & UX Upgrade Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Transform ZeroClock from a grey monochrome app into a polished, amber-accented desktop tool with proper UX primitives (button hierarchy, toast notifications, rich empty states, UI zoom, portable storage, window persistence).
**Architecture:** CSS token overhaul in main.css provides the foundation. Toast system (component + Pinia store) replaces all alert() calls. Each of the 7 views gets updated templates with new button hierarchy, amber accents, and rich empty states. Rust backend gets portable storage and window-state plugin. UI zoom applies CSS zoom on #app root, persisted via settings.
**Tech Stack:** Vue 3 Composition API, Tailwind CSS v4 with @theme tokens, Pinia stores, Lucide Vue Next icons, Chart.js, Tauri v2 with rusqlite, tauri-plugin-window-state
---
### Task 1: Design System — CSS Tokens & Utilities
**Files:**
- Modify: `src/styles/main.css`
**What to do:**
Replace the entire `@theme` block with the new charcoal + amber palette. Remove all legacy aliases. Add amber focus utility and toast animation keyframes.
**New @theme block:**
```css
@theme {
/* Background layers (charcoal, lifted from near-black) */
--color-bg-base: #1A1A18;
--color-bg-surface: #222220;
--color-bg-elevated: #2C2C28;
--color-bg-inset: #141413;
/* Text hierarchy (warm whites) */
--color-text-primary: #F5F5F0;
--color-text-secondary: #8A8A82;
--color-text-tertiary: #5A5A54;
/* Borders (bumped for charcoal) */
--color-border-subtle: #2E2E2A;
--color-border-visible: #3D3D38;
/* Accent (amber) */
--color-accent: #D97706;
--color-accent-hover: #B45309;
--color-accent-muted: rgba(217, 119, 6, 0.12);
--color-accent-text: #FBBF24;
/* Status (semantic only) */
--color-status-running: #34D399;
--color-status-warning: #EAB308;
--color-status-error: #EF4444;
--color-status-info: #3B82F6;
/* Fonts */
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
```
**Remove:** All legacy aliases (lines 29-38 in current file: `--color-background`, `--color-surface`, `--color-surface-elevated`, `--color-border`, `--color-error`, `--color-amber`, `--color-amber-hover`, `--color-success`, `--color-warning`).
**Add new keyframes** after existing animations:
```css
/* Toast enter animation */
@keyframes toast-enter {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-toast-enter {
animation: toast-enter 200ms ease-out;
}
/* Toast exit animation */
@keyframes toast-exit {
from { opacity: 1; }
to { opacity: 0; }
}
.animate-toast-exit {
animation: toast-exit 150ms ease-in forwards;
}
/* Timer colon amber pulse */
@keyframes pulse-colon {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.animate-pulse-colon {
animation: pulse-colon 1s ease-in-out infinite;
}
```
**Add global focus utility** inside the `@layer base` block:
```css
/* Amber focus ring for all interactive elements */
input:focus, select:focus, textarea:focus {
border-color: var(--color-accent) !important;
outline: none;
box-shadow: 0 0 0 2px var(--color-accent-muted);
}
```
**Update scrollbar thumb** to use new border token: `var(--color-border-subtle)` for thumb, `var(--color-text-tertiary)` for hover.
**Verify:** Run `npx vite build` — should succeed with no errors.
**Commit:** `feat: overhaul design tokens — charcoal palette + amber accent`
---
### Task 2: Toast Notification System
**Files:**
- Create: `src/stores/toast.ts`
- Create: `src/components/ToastNotification.vue`
- Modify: `src/App.vue` (add toast container)
**Toast store (`src/stores/toast.ts`):**
```typescript
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface Toast {
id: number
message: string
type: 'success' | 'error' | 'info'
exiting?: boolean
}
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([])
let nextId = 0
function addToast(message: string, type: Toast['type'] = 'info') {
const id = nextId++
toasts.value.push({ id, message, type })
// Max 3 visible
if (toasts.value.length > 3) {
toasts.value.shift()
}
// Auto-dismiss after 3s
setTimeout(() => removeToast(id), 3000)
}
function removeToast(id: number) {
const toast = toasts.value.find(t => t.id === id)
if (toast) {
toast.exiting = true
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id)
}, 150)
}
}
function success(message: string) { addToast(message, 'success') }
function error(message: string) { addToast(message, 'error') }
function info(message: string) { addToast(message, 'info') }
return { toasts, addToast, removeToast, success, error, info }
})
```
**Toast component (`src/components/ToastNotification.vue`):**
Template: Fixed top-center container. Iterates `toastStore.toasts`. Each toast is a flex row with left colored border (3px), Lucide icon (Check/AlertCircle/Info, 16px), and message text. Click to dismiss. Uses `animate-toast-enter` / `animate-toast-exit` classes.
- Success: `border-l-status-running`, green Check icon
- Error: `border-l-status-error`, red AlertCircle icon
- Info: `border-l-accent`, amber Info icon
- Body: `bg-bg-surface border border-border-subtle rounded shadow-lg`
- Text: `text-sm text-text-primary`
- Width: `w-80` (320px)
- Gap between stacked toasts: `gap-2`
Import Lucide icons: `Check`, `AlertCircle`, `Info` from `lucide-vue-next`.
**App.vue modification:**
Add `<ToastNotification />` as a sibling AFTER the main shell div (so it overlays everything). Import the component.
**Verify:** Build succeeds. Toast component renders (can test by temporarily calling `useToastStore().success('test')` in App.vue onMounted).
**Commit:** `feat: add toast notification system`
---
### Task 3: Shell — TitleBar & NavRail
**Files:**
- Modify: `src/components/TitleBar.vue`
- Modify: `src/components/NavRail.vue`
**TitleBar changes:**
1. Wordmark "ZeroClock" → change class from `text-text-secondary` to `text-accent-text`
2. No other changes — running timer section and window controls are correct
**NavRail changes:**
1. Active indicator: change `bg-text-primary` to `bg-accent`
2. Add a tooltip caret: small CSS triangle (border trick) pointing left, positioned at the left edge of the tooltip div. 4px wide, same bg as tooltip (`bg-bg-elevated`).
Tooltip caret implementation — add a `::before` pseudo-element or a small inline div:
```html
<!-- Inside the tooltip div, add as first child: -->
<div class="absolute -left-1 top-1/2 -translate-y-1/2 w-0 h-0 border-y-4 border-y-transparent border-r-4 border-r-bg-elevated"></div>
```
Note: The border-r color needs to match the tooltip background. Since Tailwind v4 may not support `border-r-bg-elevated` directly, use an inline style: `style="border-right-color: var(--color-bg-elevated)"`.
**Verify:** Build succeeds.
**Commit:** `feat: amber wordmark and NavRail active indicator`
---
### Task 4: Dashboard — Full Redesign
**Files:**
- Modify: `src/views/Dashboard.vue`
**Template changes:**
1. **Add greeting header** at top (before stats):
- Greeting computed from current hour: <6 "Good morning", <12 "Good morning", <18 "Good afternoon", else "Good evening"
- Date formatted: "Monday, February 17, 2026" using `toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })`
- Greeting: `text-lg text-text-secondary`
- Date: `text-xs text-text-tertiary mt-1`
- Wrapper: `mb-8`
2. **Stats row** — change from 3 to 4 columns (`grid-cols-4`):
- Add "Today" stat (invoke `get_reports` for today only)
- Values: change from `text-text-primary` to `text-accent-text`
- Keep labels as `text-text-tertiary uppercase`
3. **Chart bars:**
- Change `backgroundColor: '#4A4A45'` to `'#D97706'`
- Grid color: `'#2E2E2A'`
- Tick color: `'#5A5A54'`
4. **Recent entries:**
- Add "View all" link at bottom: `<router-link to="/entries" class="text-xs text-text-tertiary hover:text-text-secondary">View all</router-link>`
5. **Empty state** — wrap current empty `<p>` in a richer block:
- Show empty state when BOTH recentEntries is empty AND weekStats.totalSeconds === 0
- Centered: `flex flex-col items-center justify-center py-16`
- Lucide `Clock` icon (import it), 48px, `text-text-tertiary`
- "Start tracking your time" in `text-sm text-text-secondary mt-4`
- Description in `text-xs text-text-tertiary mt-2 max-w-xs text-center`
- `<router-link to="/timer">` styled as amber primary button: `mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded hover:bg-accent-hover transition-colors`
**Script changes:**
- Add `todayStats` ref, fetch in onMounted with `getToday()` for both start and end
- Add greeting computed
- Add date computed
- Import `Clock` from lucide-vue-next
- Import `RouterLink` from vue-router (or just use `<router-link>` which is globally available)
**Verify:** Build succeeds.
**Commit:** `feat: redesign Dashboard — greeting, amber stats, rich empty state`
---
### Task 5: Timer — Full Redesign
**Files:**
- Modify: `src/views/Timer.vue`
**Template changes:**
1. **Timer display** — split into digits and colons for amber pulse:
- Instead of `{{ timerStore.formattedTime }}` as one string, split into parts
- Create a computed that returns `{ hours, min, sec }` or render each part separately
- Colons get class `text-accent-text animate-pulse-colon` when running, `text-text-primary` when stopped
- Digits stay `text-text-primary`
2. **Start/Stop button:**
- Start: `bg-accent text-bg-base px-10 py-3 text-sm font-medium rounded hover:bg-accent-hover transition-colors duration-150`
- Stop: `bg-status-error text-white px-10 py-3 text-sm font-medium rounded hover:bg-status-error/80 transition-colors duration-150`
- Remove the old outlined border classes
3. **Replace alert()** for "Please select a project":
- Import `useToastStore`
- Replace `alert('Please select a project before starting the timer')` with `toastStore.info('Please select a project before starting the timer')`
4. **Empty state** for recent entries:
- Import `Timer` icon from lucide-vue-next (use alias like `TimerIcon` to avoid name conflict)
- Replace plain `<p>` with centered block: icon (40px) + "No entries today" + description + no CTA (user is already on the right page)
**Script changes:**
- Add `const toastStore = useToastStore()`
- Add computed for split timer parts (hours, minutes, seconds as separate strings)
- Import `useToastStore`
**Verify:** Build succeeds.
**Commit:** `feat: redesign Timer — amber Start, colon pulse, toast`
---
### Task 6: Projects — Full Redesign
**Files:**
- Modify: `src/views/Projects.vue`
**Template changes:**
1. **"+ Add" button** — replace ghost text link with small amber button:
```html
<button @click="openCreateDialog" class="px-3 py-1.5 bg-accent text-bg-base text-xs font-medium rounded hover:bg-accent-hover transition-colors duration-150">
+ Add
</button>
```
2. **Card hover** — add `hover:bg-bg-elevated` to the card div. Add `transition-all duration-150` and change left border from `border-l-2` to `border-l-[2px] hover:border-l-[3px]`.
3. **Card content** — combine client name and hourly rate on one line:
- Replace separate client `<p>` and rate `<p>` with single line:
- `{{ getClientName(project.client_id) }} · ${{ project.hourly_rate.toFixed(2) }}/hr`
- Class: `text-xs text-text-secondary mt-0.5`
4. **Color picker presets** in create/edit dialog — add a row of 8 color swatches above the color input:
```html
<div class="flex gap-2 mb-2">
<button v-for="c in colorPresets" :key="c" @click="formData.color = c"
class="w-6 h-6 rounded-full border-2 transition-colors"
:class="formData.color === c ? 'border-text-primary' : 'border-transparent'"
:style="{ backgroundColor: c }" />
</div>
```
Add to script: `const colorPresets = ['#D97706', '#3B82F6', '#8B5CF6', '#EC4899', '#10B981', '#EF4444', '#06B6D4', '#6B7280']`
5. **Dialog buttons** — Submit becomes amber primary: `bg-accent text-bg-base font-medium rounded hover:bg-accent-hover`. Cancel stays secondary.
6. **Empty state:**
- Import `FolderKanban` from lucide-vue-next
- Replace current simple empty with: icon (48px) + "No projects yet" + description + amber "Create Project" button (calls `openCreateDialog`)
**Verify:** Build succeeds.
**Commit:** `feat: redesign Projects — amber button, color presets, rich empty state`
---
### Task 7: Entries — Full Redesign
**Files:**
- Modify: `src/views/Entries.vue`
**Template changes:**
1. **Filter bar** — wrap in a container:
```html
<div class="bg-bg-surface rounded p-4 mb-6">
<!-- existing filter content -->
</div>
```
2. **Apply button** → amber primary: `bg-accent text-bg-base text-xs font-medium rounded hover:bg-accent-hover`
3. **Clear button** → ghost: `text-text-secondary text-xs hover:text-text-primary transition-colors`. Remove border classes.
4. **Table header row** — add `bg-bg-surface` to the `<thead>` or `<tr>`:
```html
<tr class="border-b border-border-subtle bg-bg-surface">
```
5. **Duration column** — change from `text-text-primary` to `text-accent-text`:
```html
<td class="px-4 py-3 text-right text-xs font-mono text-accent-text">
```
6. **Edit dialog** — Save button becomes amber primary. Cancel stays secondary.
7. **Empty state:**
- Import `List` from lucide-vue-next
- Below the filter bar, show centered empty: icon (48px) + "No entries found" + description text + amber "Go to Timer" button as router-link
**Verify:** Build succeeds.
**Commit:** `feat: redesign Entries — filter container, amber actions, rich empty state`
---
### Task 8: Reports — Full Redesign
**Files:**
- Modify: `src/views/Reports.vue`
**Template changes:**
1. **Filter bar** — wrap in `bg-bg-surface rounded p-4 mb-6` container
2. **Generate button** → amber primary. Export CSV → secondary outlined.
3. **Stats values** — change from `text-text-primary` to `text-accent-text`:
```html
<p class="text-[1.25rem] font-mono text-accent-text font-medium">
```
4. **Chart colors:**
- Grid: `'#2E2E2A'`
- Ticks: `'#5A5A54'`
- (Bar colors already use project colors, which is correct)
5. **Breakdown hours value** — change to `text-accent-text font-mono`
6. **Replace alert() calls:**
- Import `useToastStore`
- `alert('Please select a date range')` → `toastStore.info('Please select a date range')`
- `alert('No data to export')` → `toastStore.info('No data to export')`
- `alert('Failed to generate report')` → `toastStore.error('Failed to generate report')`
7. **Empty states:**
- Import `BarChart3` from lucide-vue-next
- Chart area empty: icon (48px) + "Generate a report to see your data"
- Breakdown empty: same or slightly different text
**Verify:** Build succeeds.
**Commit:** `feat: redesign Reports — amber actions and stats, toast notifications`
---
### Task 9: Invoices — Full Redesign
**Files:**
- Modify: `src/views/Invoices.vue`
**Template changes:**
1. **Active tab underline** — change from `border-text-primary` to `border-accent`:
```html
'text-text-primary border-b-2 border-accent'
```
2. **Table header row** — add `bg-bg-surface`
3. **Amount column** — change to `text-accent-text font-mono`
4. **Create form submit** → amber primary button
5. **Create form total line** — the dollar amount in the totals box: `text-accent-text font-mono`
6. **Invoice detail dialog** — Export PDF button → amber primary. Total amount → `text-accent-text`.
7. **Replace alert():**
- Import `useToastStore`
- `alert('Please select a client')` → `toastStore.info('Please select a client')`
8. **Empty state (list view):**
- Import `FileText` from lucide-vue-next
- Icon (48px) + "No invoices yet" + description + amber "Create Invoice" button (sets `view = 'create'`)
**Verify:** Build succeeds.
**Commit:** `feat: redesign Invoices — amber tabs and totals, rich empty state`
---
### Task 10: Settings — Full Redesign + UI Zoom
**Files:**
- Modify: `src/views/Settings.vue`
**Template changes:**
1. **Save Settings button** → amber primary: `bg-accent text-bg-base text-xs font-medium rounded hover:bg-accent-hover`
2. **Export button** stays secondary. Clear Data stays danger.
3. **New "Appearance" section** — add between Timer and Data sections:
```html
<div>
<h2 class="text-base font-medium text-text-primary mb-4">Appearance</h2>
<div class="pb-6 border-b border-border-subtle">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">UI Scale</p>
<p class="text-xs text-text-secondary mt-0.5">Adjust the interface zoom level</p>
</div>
<div class="flex items-center gap-2">
<button @click="decreaseZoom" class="w-8 h-8 flex items-center justify-center border border-border-visible rounded text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors" :disabled="zoomLevel <= 80">
<Minus class="w-3.5 h-3.5" />
</button>
<span class="w-12 text-center text-sm font-mono text-text-primary">{{ zoomLevel }}%</span>
<button @click="increaseZoom" class="w-8 h-8 flex items-center justify-center border border-border-visible rounded text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors" :disabled="zoomLevel >= 150">
<Plus class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</div>
```
4. **Replace all alert() calls** (5 total) with toast calls:
- `alert('Settings saved successfully')` → `toastStore.success('Settings saved')`
- `alert('Failed to save settings')` → `toastStore.error('Failed to save settings')`
- `alert('Failed to export data')` → `toastStore.error('Failed to export data')`
- `alert('Failed to clear data')` → `toastStore.error('Failed to clear data')`
- `alert('All data has been cleared')` → `toastStore.success('All data has been cleared')`
**Script changes:**
- Import `useToastStore`, `Plus`, `Minus` from lucide-vue-next
- Add `zoomLevel` ref, initialized from settings store
- Add `increaseZoom()` / `decreaseZoom()` functions:
- Steps: 80, 90, 100, 110, 120, 130, 150
- Updates the CSS zoom on `document.getElementById('app')`
- Calls `settingsStore.updateSetting('ui_zoom', zoomLevel.value.toString())`
- Load zoom from settings on mount: `zoomLevel.value = parseInt(settingsStore.settings.ui_zoom) || 100`
**Verify:** Build succeeds.
**Commit:** `feat: redesign Settings — amber save, UI zoom, toasts`
---
### Task 11: App.vue — Zoom Initialization
**Files:**
- Modify: `src/App.vue`
Add zoom initialization logic. On app mount, read the `ui_zoom` setting and apply CSS zoom to the `#app` element.
```vue
<script setup lang="ts">
import { onMounted } from 'vue'
import TitleBar from './components/TitleBar.vue'
import NavRail from './components/NavRail.vue'
import ToastNotification from './components/ToastNotification.vue'
import { useSettingsStore } from './stores/settings'
const settingsStore = useSettingsStore()
onMounted(async () => {
await settingsStore.fetchSettings()
const zoom = parseInt(settingsStore.settings.ui_zoom) || 100
const app = document.getElementById('app')
if (app) {
app.style.zoom = `${zoom}%`
}
})
</script>
<template>
<div class="h-full w-full flex flex-col bg-bg-base">
<TitleBar />
<div class="flex-1 flex overflow-hidden">
<NavRail />
<main class="flex-1 overflow-auto">
<router-view />
</main>
</div>
</div>
<ToastNotification />
</template>
```
**Verify:** Build succeeds.
**Commit:** `feat: zoom initialization and toast container in App.vue`
---
### Task 12: Rust Backend — Portable Storage
**Files:**
- Modify: `src-tauri/src/lib.rs`
- Modify: `src-tauri/Cargo.toml`
**lib.rs changes:**
Replace `get_data_dir()` to always use exe-relative path:
```rust
fn get_data_dir() -> PathBuf {
let exe_path = std::env::current_exe().unwrap();
let data_dir = exe_path.parent().unwrap().join("data");
std::fs::create_dir_all(&data_dir).ok();
data_dir
}
```
Remove `use directories` if present (it was only used as fallback, now we use exe-relative exclusively).
**Cargo.toml changes:**
Remove the `directories` dependency line:
```
directories = "5"
```
**Verify:** `cd src-tauri && cargo check` succeeds.
**Commit:** `feat: portable storage — data directory next to exe`
---
### Task 13: Rust Backend — Window State Persistence
**Files:**
- Modify: `src-tauri/Cargo.toml`
- Modify: `src-tauri/src/lib.rs`
- Modify: `src-tauri/tauri.conf.json`
**Cargo.toml:** Add dependency:
```toml
tauri-plugin-window-state = "2"
```
**tauri.conf.json changes:**
1. Remove `"center": true` from the window config (so saved position is respected)
2. Add window-state to plugins section — note: the plugin may need allowlist config. Check the plugin docs, but typically just registering in Rust is enough.
**lib.rs changes:**
Add the plugin registration in the builder chain, BEFORE `.manage()`:
```rust
.plugin(tauri_plugin_window_state::Builder::new().build())
```
The plugin automatically saves/restores window position, size, and maximized state. By default it uses the app's data directory, which is now exe-relative thanks to Task 12.
Note: For the window-state plugin to use our portable data dir, we may need to check if it respects the Tauri path resolver or if it needs explicit configuration. If it saves to AppData by default, we may need to configure its storage path. Check the plugin API.
**Verify:** `cd src-tauri && cargo check` succeeds (this will also download the new dependency).
**Commit:** `feat: persist window position and size between runs`
---
### Task 14: Build Verification & Cleanup
**Files:**
- All modified files
**Steps:**
1. Run `npx vue-tsc --noEmit` — verify no TypeScript errors
2. Run `npx vite build` — verify frontend builds
3. Run `cd src-tauri && cargo check` — verify Rust compiles
4. Check for any remaining `alert(` calls in src/ — should be zero
5. Check for any remaining references to old color tokens (--color-background, --color-surface, --color-amber, etc.) — should be zero
6. Verify no imports of removed dependencies
**Commit:** `chore: cleanup — verify build, remove stale references`

View File

@@ -7,7 +7,7 @@
<title>ZeroClock</title> <title>ZeroClock</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

15
mini-timer.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ZeroClock - Mini Timer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/mini-timer-entry.ts"></script>
</body>
</html>

1862
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "zeroclock", "name": "zeroclock",
"version": "1.0.0", "version": "1.0.1",
"description": "Time tracking desktop application", "description": "Time tracking desktop application",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -10,25 +10,36 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.0", "@tauri-apps/api": "^2.2.0",
"vue-router": "^4.5.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"pinia": "^2.3.0", "@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-notification": "^2.3.3",
"@vueuse/core": "^12.0.0", "@vueuse/core": "^12.0.0",
"@vueuse/motion": "^3.0.3",
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
"vue-chartjs": "^5.3.0", "dompurify": "^3.3.1",
"jspdf": "^2.5.0", "jspdf": "^2.5.0",
"lucide-vue-next": "^0.400.0", "lucide-vue-next": "^0.400.0",
"marked": "^17.0.3",
"pinia": "^2.3.0",
"shadcn-vue": "^2.4.3", "shadcn-vue": "^2.4.3",
"@tauri-apps/api": "^2.2.0" "vue": "^3.5.0",
"vue-chartjs": "^5.3.0",
"vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@tauri-apps/cli": "^2.2.0", "@tauri-apps/cli": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vitejs/plugin-vue": "^5.2.0", "@vitejs/plugin-vue": "^5.2.0",
"autoprefixer": "^10.4.0",
"png-to-ico": "^3.0.1",
"puppeteer-core": "^24.37.5",
"sharp": "^0.34.5",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vue-tsc": "^2.2.0", "vue-tsc": "^2.2.0"
"tailwindcss": "^4.0.0",
"@tailwindcss/vite": "^4.0.0",
"autoprefixer": "^10.4.0"
} }
} }

5884
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
[package] [package]
name = "local-time-tracker" name = "zeroclock"
version = "1.0.0" version = "1.0.1"
description = "A local time tracking app with invoicing" description = "A local time tracking app with invoicing"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
[lib] [lib]
name = "local_time_tracker_lib" name = "zeroclock_lib"
crate-type = ["lib", "cdylib", "staticlib"] crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies] [build-dependencies]
@@ -18,13 +18,28 @@ tauri-plugin-shell = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
directories = "5" tauri-plugin-window-state = "2"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
png = "0.17"
[dependencies.windows]
version = "0.58"
features = [
"Win32_UI_WindowsAndMessaging",
"Win32_System_Threading",
"Win32_System_SystemInformation",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Shell",
"Win32_Graphics_Gdi",
"Win32_Storage_FileSystem",
"Win32_Foundation",
]
[profile.release] [profile.release]
panic = "abort" panic = "abort"

View File

@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"identifier": "default", "identifier": "default",
"description": "Default capabilities for the app", "description": "Default capabilities for the app",
"windows": ["main"], "windows": ["main", "mini-timer"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-close", "core:window:allow-close",
@@ -13,6 +13,8 @@
"core:window:allow-hide", "core:window:allow-hide",
"core:window:allow-set-focus", "core:window:allow-set-focus",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"shell:allow-open", "shell:allow-open",
"dialog:allow-open", "dialog:allow-open",
"dialog:allow-save", "dialog:allow-save",
@@ -23,6 +25,10 @@
"fs:allow-write-text-file", "fs:allow-write-text-file",
"notification:allow-is-permission-granted", "notification:allow-is-permission-granted",
"notification:allow-request-permission", "notification:allow-request-permission",
"notification:allow-notify" "notification:allow-notify",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all",
"global-shortcut:allow-is-registered"
] ]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 B

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,27 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
[], [],
)?; )?;
// Migrate clients table - add new columns (safe to re-run)
let migration_columns = [
"ALTER TABLE clients ADD COLUMN company TEXT",
"ALTER TABLE clients ADD COLUMN phone TEXT",
"ALTER TABLE clients ADD COLUMN tax_id TEXT",
"ALTER TABLE clients ADD COLUMN payment_terms TEXT",
"ALTER TABLE clients ADD COLUMN notes TEXT",
"ALTER TABLE clients ADD COLUMN currency TEXT",
];
for sql in &migration_columns {
match conn.execute(sql, []) {
Ok(_) => {}
Err(e) => {
let msg = e.to_string();
if !msg.contains("duplicate column") {
return Err(e);
}
}
}
}
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS projects ( "CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -26,6 +47,27 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
[], [],
)?; )?;
// Migrate projects table - add budget columns (safe to re-run)
let project_migrations = [
"ALTER TABLE projects ADD COLUMN budget_hours REAL DEFAULT NULL",
"ALTER TABLE projects ADD COLUMN budget_amount REAL DEFAULT NULL",
"ALTER TABLE projects ADD COLUMN rounding_override INTEGER DEFAULT NULL",
"ALTER TABLE projects ADD COLUMN timeline_override TEXT DEFAULT NULL",
"ALTER TABLE projects ADD COLUMN notes TEXT",
"ALTER TABLE projects ADD COLUMN currency TEXT",
];
for sql in &project_migrations {
match conn.execute(sql, []) {
Ok(_) => {}
Err(e) => {
let msg = e.to_string();
if !msg.contains("duplicate column") {
return Err(e);
}
}
}
}
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS tasks ( "CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -37,6 +79,23 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
[], [],
)?; )?;
// Migrate tasks table (safe to re-run)
let task_migrations = [
"ALTER TABLE tasks ADD COLUMN estimated_hours REAL DEFAULT NULL",
"ALTER TABLE tasks ADD COLUMN hourly_rate REAL DEFAULT NULL",
];
for sql in &task_migrations {
match conn.execute(sql, []) {
Ok(_) => {}
Err(e) => {
let msg = e.to_string();
if !msg.contains("duplicate column") {
return Err(e);
}
}
}
}
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS time_entries ( "CREATE TABLE IF NOT EXISTS time_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -53,6 +112,22 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
[], [],
)?; )?;
// Migrate time_entries table
let time_entry_migrations = [
"ALTER TABLE time_entries ADD COLUMN billable INTEGER DEFAULT 1",
];
for sql in &time_entry_migrations {
match conn.execute(sql, []) {
Ok(_) => {}
Err(e) => {
let msg = e.to_string();
if !msg.contains("duplicate column") {
return Err(e);
}
}
}
}
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS invoices ( "CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -73,6 +148,22 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
[], [],
)?; )?;
// Migrate invoices table - add template_id column (safe to re-run)
let invoice_migrations = [
"ALTER TABLE invoices ADD COLUMN template_id TEXT DEFAULT 'clean'",
];
for sql in &invoice_migrations {
match conn.execute(sql, []) {
Ok(_) => {}
Err(e) => {
let msg = e.to_string();
if !msg.contains("duplicate column") {
return Err(e);
}
}
}
}
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS invoice_items ( "CREATE TABLE IF NOT EXISTS invoice_items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -88,6 +179,184 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
[], [],
)?; )?;
conn.execute(
"CREATE TABLE IF NOT EXISTS tracked_apps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
exe_name TEXT NOT NULL,
exe_path TEXT,
display_name TEXT,
FOREIGN KEY (project_id) REFERENCES projects(id)
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT DEFAULT '#6B7280'
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS entry_tags (
entry_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (entry_id, tag_id),
FOREIGN KEY (entry_id) REFERENCES time_entries(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
task_id INTEGER,
description TEXT,
sort_order INTEGER DEFAULT 0,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE SET NULL
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS recurring_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
task_id INTEGER,
description TEXT,
duration INTEGER DEFAULT 0,
recurrence_rule TEXT NOT NULL,
time_of_day TEXT DEFAULT '09:00',
mode TEXT DEFAULT 'prompt',
enabled INTEGER DEFAULT 1,
last_triggered TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id),
FOREIGN KEY (task_id) REFERENCES tasks(id)
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
client_id INTEGER,
category TEXT DEFAULT 'other',
description TEXT,
amount REAL DEFAULT 0,
date TEXT NOT NULL,
receipt_path TEXT,
invoiced INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id),
FOREIGN KEY (client_id) REFERENCES clients(id)
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS timeline_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
exe_name TEXT,
exe_path TEXT,
window_title TEXT,
started_at TEXT NOT NULL,
ended_at TEXT,
duration INTEGER DEFAULT 0,
FOREIGN KEY (project_id) REFERENCES projects(id)
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS calendar_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
url TEXT,
last_synced TEXT,
sync_interval INTEGER DEFAULT 30,
enabled INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
uid TEXT,
summary TEXT,
start_time TEXT,
end_time TEXT,
duration INTEGER DEFAULT 0,
location TEXT,
synced_at TEXT,
FOREIGN KEY (source_id) REFERENCES calendar_sources(id) ON DELETE CASCADE
)",
[],
)?;
// Migrate calendar_events table - add description column (safe to re-run)
let calendar_migrations = [
"ALTER TABLE calendar_events ADD COLUMN description TEXT",
];
for sql in &calendar_migrations {
match conn.execute(sql, []) {
Ok(_) => {}
Err(e) => {
let msg = e.to_string();
if !msg.contains("duplicate column") {
return Err(e);
}
}
}
}
conn.execute(
"CREATE TABLE IF NOT EXISTS timesheet_locks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_start TEXT NOT NULL UNIQUE,
status TEXT DEFAULT 'locked',
locked_at TEXT DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS entry_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
project_id INTEGER NOT NULL REFERENCES projects(id),
task_id INTEGER REFERENCES tasks(id),
description TEXT,
duration INTEGER NOT NULL DEFAULT 0,
billable INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS timesheet_rows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_start TEXT NOT NULL,
project_id INTEGER NOT NULL REFERENCES projects(id),
task_id INTEGER REFERENCES tasks(id),
sort_order INTEGER NOT NULL DEFAULT 0
)",
[],
)?;
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS settings ( "CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -96,9 +365,41 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
[], [],
)?; )?;
conn.execute(
"CREATE TABLE IF NOT EXISTS invoice_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER NOT NULL,
amount REAL NOT NULL,
date TEXT NOT NULL,
method TEXT,
notes TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS recurring_invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
template_id TEXT,
line_items_json TEXT NOT NULL,
tax_rate REAL DEFAULT 0,
discount REAL DEFAULT 0,
notes TEXT,
recurrence_rule TEXT NOT NULL,
next_due_date TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients(id)
)",
[],
)?;
// Insert default settings // Insert default settings
conn.execute( conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('default_hourly_rate', '50')", "INSERT OR IGNORE INTO settings (key, value) VALUES ('hourly_rate', '50')",
[], [],
)?; )?;
conn.execute( conn.execute(
@@ -113,6 +414,26 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
"INSERT OR IGNORE INTO settings (key, value) VALUES ('reminder_interval', '30')", "INSERT OR IGNORE INTO settings (key, value) VALUES ('reminder_interval', '30')",
[], [],
)?; )?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('app_tracking_mode', 'auto')",
[],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('app_check_interval', '5')",
[],
)?;
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('goals_enabled', 'true')", [])?;
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('daily_goal_hours', '8')", [])?;
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('weekly_goal_hours', '40')", [])?;
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('rounding_enabled', 'false')", [])?;
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('rounding_increment', '15')", [])?;
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('rounding_method', 'nearest')", [])?;
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('theme_mode', 'dark')", [])?;
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('accent_color', 'amber')", [])?;
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('shortcut_toggle_timer', 'CmdOrCtrl+Shift+T')", [])?;
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('shortcut_show_app', 'CmdOrCtrl+Shift+Z')", [])?;
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('mini_timer_opacity', '90')", [])?;
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('timeline_recording', 'off')", [])?;
Ok(()) Ok(())
} }

View File

@@ -1,20 +1,20 @@
use rusqlite::Connection; use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{Manager, State};
use std::path::PathBuf; use std::path::PathBuf;
use tauri::Manager;
mod database; mod database;
mod commands; mod commands;
mod os_detection;
pub struct AppState { pub struct AppState {
pub db: Mutex<Connection>, pub db: Mutex<Connection>,
pub data_dir: PathBuf,
} }
fn get_data_dir() -> PathBuf { fn get_data_dir() -> PathBuf {
let exe_path = std::env::current_exe().unwrap(); let exe_path = std::env::current_exe().unwrap();
let exe_dir = exe_path.parent().unwrap(); let data_dir = exe_path.parent().unwrap().join("data");
let data_dir = exe_dir.join("data");
std::fs::create_dir_all(&data_dir).ok(); std::fs::create_dir_all(&data_dir).ok();
data_dir data_dir
} }
@@ -28,25 +28,33 @@ pub fn run() {
let conn = Connection::open(&db_path).expect("Failed to open database"); let conn = Connection::open(&db_path).expect("Failed to open database");
database::init_db(&conn).expect("Failed to initialize database"); database::init_db(&conn).expect("Failed to initialize database");
commands::seed_default_templates(&data_dir);
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_window_state::Builder::new()
.with_denylist(&["mini-timer"])
.build())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.manage(AppState { db: Mutex::new(conn) }) .plugin(tauri_plugin_global_shortcut::Builder::new().build())
.manage(AppState { db: Mutex::new(conn), data_dir: data_dir.clone() })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::get_clients, commands::get_clients,
commands::create_client, commands::create_client,
commands::update_client, commands::update_client,
commands::delete_client, commands::delete_client,
commands::get_client_dependents,
commands::get_projects, commands::get_projects,
commands::create_project, commands::create_project,
commands::update_project, commands::update_project,
commands::delete_project, commands::delete_project,
commands::get_project_dependents,
commands::get_tasks, commands::get_tasks,
commands::create_task, commands::create_task,
commands::delete_task, commands::delete_task,
commands::update_task,
commands::get_time_entries, commands::get_time_entries,
commands::create_time_entry, commands::create_time_entry,
commands::update_time_entry, commands::update_time_entry,
@@ -54,8 +62,98 @@ pub fn run() {
commands::get_reports, commands::get_reports,
commands::create_invoice, commands::create_invoice,
commands::get_invoices, commands::get_invoices,
commands::update_invoice,
commands::delete_invoice,
commands::update_invoice_template,
commands::get_invoice_items,
commands::create_invoice_item,
commands::delete_invoice_items,
commands::save_invoice_items_batch,
commands::get_settings, commands::get_settings,
commands::update_settings, commands::update_settings,
commands::export_data,
commands::clear_all_data,
commands::get_idle_seconds,
commands::get_visible_windows,
commands::get_running_processes,
commands::get_tracked_apps,
commands::add_tracked_app,
commands::remove_tracked_app,
commands::get_tags,
commands::create_tag,
commands::update_tag,
commands::delete_tag,
commands::get_entry_tags,
commands::set_entry_tags,
commands::get_project_budget_status,
commands::get_favorites,
commands::create_favorite,
commands::delete_favorite,
commands::reorder_favorites,
commands::get_goal_progress,
commands::get_profitability_report,
commands::get_timesheet_data,
commands::import_entries,
commands::import_json_data,
commands::save_binary_file,
commands::open_mini_timer,
commands::close_mini_timer,
commands::get_invoice_templates,
commands::get_recurring_entries,
commands::create_recurring_entry,
commands::update_recurring_entry,
commands::delete_recurring_entry,
commands::update_recurring_last_triggered,
commands::get_expenses,
commands::create_expense,
commands::update_expense,
commands::delete_expense,
commands::get_uninvoiced_expenses,
commands::mark_expenses_invoiced,
commands::get_timeline_events,
commands::create_timeline_event,
commands::update_timeline_event_ended,
commands::delete_timeline_events,
commands::clear_all_timeline_data,
commands::get_calendar_sources,
commands::create_calendar_source,
commands::update_calendar_source,
commands::delete_calendar_source,
commands::import_ics_file,
commands::get_calendar_events,
commands::lock_timesheet_week,
commands::unlock_timesheet_week,
commands::get_timesheet_locks,
commands::is_week_locked,
commands::update_invoice_status,
commands::check_overdue_invoices,
commands::get_time_entries_paginated,
commands::bulk_delete_entries,
commands::bulk_update_entries_project,
commands::bulk_update_entries_billable,
commands::upsert_timesheet_entry,
commands::get_entry_templates,
commands::create_entry_template,
commands::delete_entry_template,
commands::update_entry_template,
commands::get_timesheet_rows,
commands::save_timesheet_rows,
commands::get_previous_week_structure,
commands::auto_backup,
commands::search_entries,
commands::list_backup_files,
commands::delete_backup_file,
commands::get_recent_descriptions,
commands::check_entry_overlap,
commands::get_task_actuals,
commands::get_invoice_payments,
commands::add_invoice_payment,
commands::delete_invoice_payment,
commands::get_recurring_invoices,
commands::create_recurring_invoice,
commands::update_recurring_invoice,
commands::delete_recurring_invoice,
commands::check_recurring_invoices,
]) ])
.setup(|app| { .setup(|app| {
#[cfg(desktop)] #[cfg(desktop)]
@@ -68,8 +166,9 @@ pub fn run() {
let menu = Menu::with_items(app, &[&show, &quit])?; let menu = Menu::with_items(app, &[&show, &quit])?;
let _tray = TrayIconBuilder::new() let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu) .menu(&menu)
.menu_on_left_click(false) .show_menu_on_left_click(false)
.on_menu_event(|app, event| { .on_menu_event(|app, event| {
match event.id.as_ref() { match event.id.as_ref() {
"quit" => { "quit" => {

View File

@@ -4,5 +4,5 @@
)] )]
fn main() { fn main() {
local_time_tracker_lib::run(); zeroclock_lib::run();
} }

View File

@@ -0,0 +1,348 @@
use serde::Serialize;
use std::collections::HashMap;
use windows::core::{PCWSTR, PWSTR};
use windows::Win32::Foundation::{BOOL, HWND, LPARAM};
use windows::Win32::Graphics::Gdi::{
CreateCompatibleDC, DeleteDC, DeleteObject, GetDIBits, GetObjectW, BITMAP, BITMAPINFO,
BITMAPINFOHEADER, DIB_RGB_COLORS, HBITMAP,
};
use windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES;
use windows::Win32::System::Threading::{
OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT, PROCESS_QUERY_LIMITED_INFORMATION,
};
use windows::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO};
use windows::Win32::UI::Shell::{SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_SMALLICON};
use windows::Win32::UI::WindowsAndMessaging::{
DestroyIcon, EnumWindows, GetIconInfo, GetWindowTextLengthW, GetWindowTextW,
GetWindowThreadProcessId, IsIconic, IsWindowVisible, ICONINFO,
};
#[derive(Debug, Serialize, Clone)]
pub struct WindowInfo {
pub exe_name: String,
pub exe_path: String,
pub title: String,
pub display_name: String,
pub icon: Option<String>,
}
pub fn get_system_idle_seconds() -> u64 {
unsafe {
let mut info = LASTINPUTINFO {
cbSize: std::mem::size_of::<LASTINPUTINFO>() as u32,
dwTime: 0,
};
if GetLastInputInfo(&mut info).as_bool() {
let tick_count = windows::Win32::System::SystemInformation::GetTickCount();
let idle_ms = tick_count.wrapping_sub(info.dwTime);
(idle_ms / 1000) as u64
} else {
0
}
}
}
fn get_process_exe_path(pid: u32) -> Option<String> {
unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
let mut buf = [0u16; 1024];
let mut size = buf.len() as u32;
QueryFullProcessImageNameW(handle, PROCESS_NAME_FORMAT(0), PWSTR(buf.as_mut_ptr()), &mut size).ok()?;
let _ = windows::Win32::Foundation::CloseHandle(handle);
let path = String::from_utf16_lossy(&buf[..size as usize]);
Some(path)
}
}
fn get_window_title(hwnd: HWND) -> String {
unsafe {
let len = GetWindowTextLengthW(hwnd);
if len == 0 {
return String::new();
}
let mut buf = vec![0u16; (len + 1) as usize];
let copied = GetWindowTextW(hwnd, &mut buf);
String::from_utf16_lossy(&buf[..copied as usize])
}
}
fn exe_name_from_path(path: &str) -> String {
path.rsplit('\\').next().unwrap_or(path).to_string()
}
fn display_name_from_exe(exe_name: &str) -> String {
exe_name
.strip_suffix(".exe")
.or_else(|| exe_name.strip_suffix(".EXE"))
.unwrap_or(exe_name)
.to_string()
}
struct EnumState {
windows: Vec<WindowInfo>,
include_minimized: bool,
}
unsafe extern "system" fn enum_windows_callback(hwnd: HWND, lparam: LPARAM) -> BOOL {
let state = &mut *(lparam.0 as *mut EnumState);
if !IsWindowVisible(hwnd).as_bool() {
return BOOL(1);
}
if !state.include_minimized && IsIconic(hwnd).as_bool() {
return BOOL(1);
}
let title = get_window_title(hwnd);
if title.is_empty() {
return BOOL(1);
}
let mut pid: u32 = 0;
GetWindowThreadProcessId(hwnd, Some(&mut pid));
if pid == 0 {
return BOOL(1);
}
if let Some(exe_path) = get_process_exe_path(pid) {
let exe_name = exe_name_from_path(&exe_path);
let display_name = display_name_from_exe(&exe_name);
state.windows.push(WindowInfo {
exe_name,
exe_path,
title,
display_name,
icon: None,
});
}
BOOL(1)
}
pub fn enumerate_visible_windows() -> Vec<WindowInfo> {
let mut state = EnumState {
windows: Vec::new(),
include_minimized: false,
};
unsafe {
let _ = EnumWindows(
Some(enum_windows_callback),
LPARAM(&mut state as *mut EnumState as isize),
);
}
state.windows
}
pub fn enumerate_running_processes() -> Vec<WindowInfo> {
let mut state = EnumState {
windows: Vec::new(),
include_minimized: true,
};
unsafe {
let _ = EnumWindows(
Some(enum_windows_callback),
LPARAM(&mut state as *mut EnumState as isize),
);
}
// Deduplicate by exe_path (case-insensitive)
let mut seen = HashMap::new();
let mut result = Vec::new();
for w in state.windows {
let key = w.exe_path.to_lowercase();
if !seen.contains_key(&key) {
seen.insert(key, true);
result.push(w);
}
}
result.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()));
// Extract icons for the deduplicated list
for w in &mut result {
w.icon = extract_icon_data_url(&w.exe_path);
}
result
}
// --- Icon extraction ---
fn extract_icon_data_url(exe_path: &str) -> Option<String> {
unsafe {
let wide: Vec<u16> = exe_path.encode_utf16().chain(std::iter::once(0)).collect();
let mut fi = SHFILEINFOW::default();
let res = SHGetFileInfoW(
PCWSTR(wide.as_ptr()),
FILE_FLAGS_AND_ATTRIBUTES(0),
Some(&mut fi),
std::mem::size_of::<SHFILEINFOW>() as u32,
SHGFI_ICON | SHGFI_SMALLICON,
);
if res == 0 || fi.hIcon.is_invalid() {
return None;
}
let hicon = fi.hIcon;
let mut ii = ICONINFO::default();
if GetIconInfo(hicon, &mut ii).is_err() {
let _ = DestroyIcon(hicon);
return None;
}
let result = extract_icon_pixels(ii.hbmColor, ii.hbmMask).and_then(|(rgba, w, h)| {
let png_bytes = encode_rgba_to_png(&rgba, w, h)?;
Some(format!("data:image/png;base64,{}", base64_encode(&png_bytes)))
});
// Cleanup
if !ii.hbmColor.is_invalid() {
let _ = DeleteObject(ii.hbmColor);
}
if !ii.hbmMask.is_invalid() {
let _ = DeleteObject(ii.hbmMask);
}
let _ = DestroyIcon(hicon);
result
}
}
unsafe fn extract_icon_pixels(
hbm_color: HBITMAP,
hbm_mask: HBITMAP,
) -> Option<(Vec<u8>, u32, u32)> {
if hbm_color.is_invalid() {
return None;
}
let mut bm = BITMAP::default();
if GetObjectW(
hbm_color,
std::mem::size_of::<BITMAP>() as i32,
Some(&mut bm as *mut BITMAP as *mut std::ffi::c_void),
) == 0
{
return None;
}
let w = bm.bmWidth as u32;
let h = bm.bmHeight as u32;
if w == 0 || h == 0 {
return None;
}
let hdc = CreateCompatibleDC(None);
// Read color bitmap as 32-bit BGRA
let mut bmi = make_bmi(w, h);
let mut bgra = vec![0u8; (w * h * 4) as usize];
let lines = GetDIBits(
hdc,
hbm_color,
0,
h,
Some(bgra.as_mut_ptr() as *mut std::ffi::c_void),
&mut bmi,
DIB_RGB_COLORS,
);
if lines == 0 {
let _ = DeleteDC(hdc);
return None;
}
// Check if any pixel has a non-zero alpha
let has_alpha = bgra.chunks_exact(4).any(|px| px[3] != 0);
if !has_alpha && !hbm_mask.is_invalid() {
// Read the mask bitmap as 32-bit to determine transparency
let mut mask_bmi = make_bmi(w, h);
let mut mask = vec![0u8; (w * h * 4) as usize];
GetDIBits(
hdc,
hbm_mask,
0,
h,
Some(mask.as_mut_ptr() as *mut std::ffi::c_void),
&mut mask_bmi,
DIB_RGB_COLORS,
);
// Mask: black (0,0,0) = opaque, white = transparent
for i in (0..bgra.len()).step_by(4) {
bgra[i + 3] = if mask[i] == 0 && mask[i + 1] == 0 && mask[i + 2] == 0 {
255
} else {
0
};
}
} else if !has_alpha {
// No mask, assume fully opaque
for px in bgra.chunks_exact_mut(4) {
px[3] = 255;
}
}
let _ = DeleteDC(hdc);
// BGRA -> RGBA
for px in bgra.chunks_exact_mut(4) {
px.swap(0, 2);
}
Some((bgra, w, h))
}
fn make_bmi(w: u32, h: u32) -> BITMAPINFO {
BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: w as i32,
biHeight: -(h as i32), // top-down
biPlanes: 1,
biBitCount: 32,
biCompression: 0, // BI_RGB
..Default::default()
},
..Default::default()
}
}
fn encode_rgba_to_png(pixels: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
let mut buf = Vec::new();
{
let mut encoder = png::Encoder::new(&mut buf, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().ok()?;
writer.write_image_data(pixels).ok()?;
writer.finish().ok()?;
}
Some(buf)
}
fn base64_encode(data: &[u8]) -> String {
const CHARS: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
for chunk in data.chunks(3) {
let b = [
chunk[0],
chunk.get(1).copied().unwrap_or(0),
chunk.get(2).copied().unwrap_or(0),
];
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
out.push(CHARS[((n >> 18) & 63) as usize] as char);
out.push(CHARS[((n >> 12) & 63) as usize] as char);
out.push(if chunk.len() > 1 {
CHARS[((n >> 6) & 63) as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
CHARS[(n & 63) as usize] as char
} else {
'='
});
}
out
}

View File

@@ -1,33 +1,40 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "LocalTimeTracker", "productName": "ZeroClock",
"version": "1.0.0", "version": "1.0.1",
"identifier": "com.localtimetracker.app", "identifier": "com.localtimetracker.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build", "beforeBuildCommand": "npm run build",
"frontendDist": "../dist", "frontendDist": "../dist"
"devtools": true
}, },
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "LocalTimeTracker", "title": "ZeroClock",
"width": 1200, "width": 1200,
"height": 800, "height": 800,
"minWidth": 800, "minWidth": 800,
"minHeight": 600, "minHeight": 600,
"decorations": false, "decorations": false,
"transparent": false, "transparent": false,
"resizable": true, "resizable": true
"center": true },
{
"label": "mini-timer",
"url": "mini-timer.html",
"title": "Timer",
"width": 300,
"height": 80,
"decorations": false,
"transparent": false,
"resizable": false,
"alwaysOnTop": true,
"skipTaskbar": true,
"visible": false
} }
], ],
"trayIcon": {
"iconPath": "icons/icon.png",
"iconAsTemplate": true
},
"security": { "security": {
"csp": null "csp": null
} }

View File

@@ -1,38 +1,452 @@
<script setup lang="ts"> <script setup lang="ts">
// Main App component - layout placeholder import { onMounted, ref, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import TitleBar from './components/TitleBar.vue'
import NavRail from './components/NavRail.vue'
import ToastNotification from './components/ToastNotification.vue'
import { useSettingsStore } from './stores/settings'
import { useToastStore } from './stores/toast'
import { useTimerStore } from './stores/timer'
import { useRecurringStore } from './stores/recurring'
import { loadAndApplyTimerFont } from './utils/fonts'
import { loadAndApplyUIFont } from './utils/uiFonts'
import { useAnnouncer } from './composables/useAnnouncer'
import { audioEngine, DEFAULT_EVENTS } from './utils/audio'
import type { SoundEvent } from './utils/audio'
import TourOverlay from './components/TourOverlay.vue'
import RecurringPromptDialog from './components/RecurringPromptDialog.vue'
import TimerSaveDialog from './components/TimerSaveDialog.vue'
import QuickEntryDialog from './components/QuickEntryDialog.vue'
import KeyboardShortcutsDialog from './components/KeyboardShortcutsDialog.vue'
import GlobalSearchDialog from './components/GlobalSearchDialog.vue'
import { useOnboardingStore } from './stores/onboarding'
import { useProjectsStore } from './stores/projects'
import { useInvoicesStore } from './stores/invoices'
const settingsStore = useSettingsStore()
const recurringStore = useRecurringStore()
const timerStore = useTimerStore()
const { announcement } = useAnnouncer()
const showQuickEntry = ref(false)
const showShortcuts = ref(false)
const showSearch = ref(false)
function getProjectName(projectId?: number): string {
if (!projectId) return ''
const projectsStore = useProjectsStore()
return projectsStore.projects.find(p => p.id === projectId)?.name || ''
}
function getProjectColor(projectId?: number): string {
if (!projectId) return '#6B7280'
const projectsStore = useProjectsStore()
return projectsStore.projects.find(p => p.id === projectId)?.color || '#6B7280'
}
let shortcutRegistering = false
async function registerShortcuts() {
if (shortcutRegistering) return
shortcutRegistering = true
try {
const { unregisterAll, register } = await import('@tauri-apps/plugin-global-shortcut')
await unregisterAll()
const toggleKey = settingsStore.settings.shortcut_toggle_timer || 'CmdOrCtrl+Shift+T'
const showKey = settingsStore.settings.shortcut_show_app || 'CmdOrCtrl+Shift+Z'
await register(toggleKey, () => {
const timerStore = useTimerStore()
if (timerStore.isStopped) {
if (timerStore.selectedProjectId) timerStore.start()
} else {
timerStore.stop()
}
})
await register(showKey, async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
const win = getCurrentWindow()
await win.show()
await win.setFocus()
})
const quickEntryKey = settingsStore.settings.shortcut_quick_entry || 'CmdOrCtrl+Shift+N'
await register(quickEntryKey, async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
const win = getCurrentWindow()
await win.show()
await win.setFocus()
showQuickEntry.value = true
})
} catch (e) {
console.error('Failed to register shortcuts:', e)
} finally {
shortcutRegistering = false
}
}
function applyTheme() {
const el = document.documentElement
const mode = settingsStore.settings.theme_mode || 'dark'
const accent = settingsStore.settings.accent_color || 'amber'
if (mode === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
el.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
} else {
el.setAttribute('data-theme', mode)
}
el.setAttribute('data-accent', accent)
}
function daysDiff(a: string, b: string): number {
const ms = new Date(b).getTime() - new Date(a).getTime()
return Math.floor(ms / 86400000)
}
async function checkScheduledBackup() {
const s = settingsStore.settings
if (s.auto_backup !== 'true' || !s.backup_path) return
const lastBackup = s.auto_backup_last || ''
const frequency = s.auto_backup_frequency || 'daily'
const retention = parseInt(s.auto_backup_retention || '7')
const today = new Date().toISOString().split('T')[0]
const isDue = !lastBackup || (frequency === 'daily' && lastBackup < today) ||
(frequency === 'weekly' && daysDiff(lastBackup, today) >= 7)
if (!isDue) return
try {
await invoke('auto_backup', { backupDir: s.backup_path })
await settingsStore.updateSetting('auto_backup_last', today)
const toastStore = useToastStore()
const files = await invoke<any[]>('list_backup_files', { backupDir: s.backup_path })
if (files.length > retention) {
for (const old of files.slice(retention)) {
await invoke('delete_backup_file', { path: old.path })
}
}
toastStore.success('Auto-backup completed')
} catch (e) {
console.error('Scheduled backup failed:', e)
}
}
function applyMotion() {
const setting = settingsStore.settings.reduce_motion || 'system'
const el = document.documentElement
if (setting === 'on') {
el.classList.add('reduce-motion')
} else if (setting === 'off') {
el.classList.remove('reduce-motion')
} else {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
el.classList.add('reduce-motion')
} else {
el.classList.remove('reduce-motion')
}
}
}
onMounted(async () => {
await settingsStore.fetchSettings()
const onboardingStore = useOnboardingStore()
await onboardingStore.load()
await timerStore.restoreState()
const zoom = parseInt(settingsStore.settings.ui_zoom) || 100
const app = document.getElementById('app')
if (app) {
(app.style as any).zoom = `${zoom}%`
}
applyTheme()
applyMotion()
loadAndApplyTimerFont(settingsStore.settings.timer_font || 'JetBrains Mono')
const dyslexiaMode = settingsStore.settings.dyslexia_mode === 'true'
if (dyslexiaMode) {
loadAndApplyUIFont('OpenDyslexic')
} else {
const uiFont = settingsStore.settings.ui_font
if (uiFont && uiFont !== 'Inter') {
loadAndApplyUIFont(uiFont)
}
}
// Load audio settings
const soundEnabled = settingsStore.settings.sound_enabled === 'true'
const soundMode = (settingsStore.settings.sound_mode || 'synthesized') as 'synthesized' | 'system' | 'custom'
const soundVolume = parseInt(settingsStore.settings.sound_volume) || 70
let soundEvents: Record<string, boolean> = {}
try {
soundEvents = JSON.parse(settingsStore.settings.sound_events || '{}')
} catch { /* use defaults */ }
audioEngine.updateSettings({
enabled: soundEnabled,
mode: soundMode,
volume: soundVolume,
events: { ...DEFAULT_EVENTS, ...soundEvents } as Record<SoundEvent, boolean>,
})
// Initialize persistent notifications setting
const toastStore = useToastStore()
toastStore.setPersistentNotifications(settingsStore.settings.persistent_notifications === 'true')
await recurringStore.fetchEntries()
recurringStore.checkRecurrences()
setInterval(() => recurringStore.checkRecurrences(), 60000)
setInterval(() => onboardingStore.detectCompletions(), 5 * 60000)
// Background calendar sync
async function syncCalendars() {
try {
const sources = await invoke<any[]>('get_calendar_sources')
for (const source of sources) {
if (source.source_type === 'url' && source.enabled && source.url) {
try {
const resp = await fetch(source.url)
if (resp.ok) {
const content = await resp.text()
await invoke('import_ics_file', { sourceId: source.id, content })
}
} catch (e) {
console.error('Calendar sync failed for', source.name, e)
}
}
}
} catch (e) {
console.error('Failed to sync calendars:', e)
}
}
syncCalendars()
setInterval(syncCalendars, 30 * 60000)
const invoicesStore = useInvoicesStore()
await invoicesStore.fetchInvoices()
const overdueCount = await invoicesStore.checkOverdue()
if (overdueCount > 0) {
const toastStore = useToastStore()
toastStore.info(`${overdueCount} invoice(s) now overdue`)
}
await checkScheduledBackup()
// End-of-day reminder and weekly summary checks
const reminderState = { eodShownToday: '', weeklySummaryShownWeek: '' }
async function checkReminders() {
const now = new Date()
const todayStr = now.toISOString().split('T')[0]
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
// End-of-day reminder
if (settingsStore.settings.eod_reminder_enabled === 'true' && reminderState.eodShownToday !== todayStr) {
const reminderTime = settingsStore.settings.eod_reminder_time || '17:00'
if (currentTime >= reminderTime) {
reminderState.eodShownToday = todayStr
try {
const entries = await invoke<any[]>('get_time_entries', { startDate: todayStr, endDate: todayStr })
const totalSeconds = entries.reduce((sum: number, e: any) => sum + (e.duration || 0), 0)
const totalHours = totalSeconds / 3600
const goalHours = parseFloat(settingsStore.settings.daily_goal_hours) || 8
if (totalHours < goalHours) {
const remaining = (goalHours - totalHours).toFixed(1)
const toastStore = useToastStore()
toastStore.info(`End of day: ${totalHours.toFixed(1)}h logged today, ${remaining}h remaining to reach your ${goalHours}h goal`)
}
} catch {
// ignore
}
}
}
// Weekly summary (Monday check)
if (settingsStore.settings.weekly_summary_enabled === 'true' && now.getDay() === 1) {
const weekId = todayStr
if (reminderState.weeklySummaryShownWeek !== weekId && now.getHours() >= 9) {
reminderState.weeklySummaryShownWeek = weekId
try {
const lastMonday = new Date(now)
lastMonday.setDate(now.getDate() - 7)
const lastSunday = new Date(now)
lastSunday.setDate(now.getDate() - 1)
const entries = await invoke<any[]>('get_time_entries', {
startDate: lastMonday.toISOString().split('T')[0],
endDate: lastSunday.toISOString().split('T')[0],
})
const totalSeconds = entries.reduce((sum: number, e: any) => sum + (e.duration || 0), 0)
const totalHours = totalSeconds / 3600
const goalHours = parseFloat(settingsStore.settings.weekly_goal_hours) || 40
const toastStore = useToastStore()
toastStore.info(`Weekly summary: ${totalHours.toFixed(1)}h logged last week (goal: ${goalHours}h)`)
} catch {
// ignore
}
}
}
}
checkReminders()
setInterval(checkReminders, 60000)
registerShortcuts()
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
showSearch.value = true
return
}
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
if ((e.target as HTMLElement)?.isContentEditable) return
e.preventDefault()
showShortcuts.value = !showShortcuts.value
}
})
// Handle window close - backup and optionally hide to tray
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
const win = getCurrentWindow()
win.onCloseRequested(async (event) => {
if (settingsStore.settings.auto_backup === 'true' && settingsStore.settings.backup_path) {
try {
await invoke('auto_backup', { backupDir: settingsStore.settings.backup_path })
} catch (e) {
console.error('Auto-backup failed:', e)
}
}
if (settingsStore.settings.close_to_tray === 'true') {
event.preventDefault()
await win.hide()
} else {
await win.destroy()
}
})
} catch (e) {
console.error('Failed to register close handler:', e)
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (settingsStore.settings.theme_mode === 'system') applyTheme()
})
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => {
if (settingsStore.settings.reduce_motion === 'system' || !settingsStore.settings.reduce_motion) {
applyMotion()
}
})
})
watch(() => [settingsStore.settings.theme_mode, settingsStore.settings.accent_color], () => {
applyTheme()
})
watch(() => settingsStore.settings.reduce_motion, () => {
applyMotion()
})
watch(() => settingsStore.settings.timer_font, (newFont) => {
if (newFont) loadAndApplyTimerFont(newFont)
})
watch(() => settingsStore.settings.dyslexia_mode, (val) => {
if (val === 'true') {
loadAndApplyUIFont('OpenDyslexic')
} else {
const uiFont = settingsStore.settings.ui_font || 'Inter'
loadAndApplyUIFont(uiFont)
}
})
watch(() => settingsStore.settings.ui_font, (newFont) => {
if (settingsStore.settings.dyslexia_mode !== 'true' && newFont) {
loadAndApplyUIFont(newFont)
}
})
watch(() => [settingsStore.settings.shortcut_toggle_timer, settingsStore.settings.shortcut_show_app, settingsStore.settings.shortcut_quick_entry], () => {
registerShortcuts()
})
watch(() => settingsStore.settings.sound_enabled, (val) => {
audioEngine.updateSettings({ enabled: val === 'true' })
})
watch(() => settingsStore.settings.sound_mode, (val) => {
if (val) audioEngine.updateSettings({ mode: val as 'synthesized' | 'system' | 'custom' })
})
watch(() => settingsStore.settings.sound_volume, (val) => {
audioEngine.updateSettings({ volume: parseInt(val) || 70 })
})
watch(() => settingsStore.settings.sound_events, (val) => {
if (val) {
try {
const parsed = JSON.parse(val)
audioEngine.updateSettings({ events: { ...DEFAULT_EVENTS, ...parsed } as Record<SoundEvent, boolean> })
} catch { /* ignore parse errors */ }
}
})
watch(() => settingsStore.settings.persistent_notifications, (val) => {
const toastStore = useToastStore()
toastStore.setPersistentNotifications(val === 'true')
})
</script> </script>
<template> <template>
<div class="h-full w-full flex flex-col bg-background"> <a href="#main-content" class="sr-only sr-only-focusable fixed top-0 left-0 z-[200] bg-accent text-white px-4 py-2 rounded-br-lg">
<!-- TitleBar placeholder --> Skip to main content
<header class="h-10 flex items-center justify-between px-4 bg-surface border-b border-border" data-tauri-drag-region> </a>
<div class="flex items-center gap-2"> <div class="h-full w-full flex flex-col bg-bg-base">
<span class="text-amber font-semibold">ZeroClock</span> <TitleBar />
</div>
<div class="flex items-center gap-2">
<!-- Window controls would go here -->
</div>
</header>
<!-- TimerBar placeholder -->
<div class="h-16 flex items-center justify-center bg-surface border-b border-border">
<span class="text-text-secondary">Timer Bar Placeholder</span>
</div>
<!-- Main content area -->
<div class="flex-1 flex overflow-hidden"> <div class="flex-1 flex overflow-hidden">
<!-- Sidebar placeholder --> <NavRail />
<aside class="w-16 flex flex-col items-center py-4 bg-surface border-r border-border"> <main id="main-content" class="flex-1 overflow-auto" tabindex="-1">
<div class="text-text-secondary text-sm">Sidebar</div> <router-view v-slot="{ Component }">
</aside> <Transition name="page" mode="out-in" :duration="{ enter: 250, leave: 150 }">
<component :is="Component" :key="$route.path" />
<!-- Main content --> </Transition>
<main class="flex-1 p-6 overflow-auto"> </router-view>
<router-view />
</main> </main>
</div> </div>
</div> </div>
<ToastNotification />
<RecurringPromptDialog
:show="recurringStore.pendingPrompt !== null"
:project-name="getProjectName(recurringStore.pendingPrompt?.project_id)"
:project-color="getProjectColor(recurringStore.pendingPrompt?.project_id)"
:task-name="''"
:description="recurringStore.pendingPrompt?.description || ''"
:duration="recurringStore.pendingPrompt?.duration || 0"
:time-of-day="recurringStore.pendingPrompt?.time_of_day || ''"
@confirm="recurringStore.confirmPrompt()"
@snooze="recurringStore.snoozePrompt()"
@skip="recurringStore.skipPrompt()"
/>
<TimerSaveDialog
:show="timerStore.showSaveDialog"
:elapsed-seconds="timerStore.pendingStopDuration"
:mode="timerStore.saveDialogMode"
@save="timerStore.handleSaveDialogSave"
@discard="timerStore.handleSaveDialogDiscard"
@cancel="timerStore.handleSaveDialogCancel"
/>
<QuickEntryDialog
:show="showQuickEntry"
@close="showQuickEntry = false"
@saved="showQuickEntry = false"
/>
<div id="route-announcer" class="sr-only" aria-live="polite" aria-atomic="true"></div>
<div id="announcer" class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div>
<TourOverlay />
<KeyboardShortcutsDialog :show="showShortcuts" @close="showShortcuts = false" />
<GlobalSearchDialog :show="showSearch" @close="showSearch = false" />
</template> </template>
<style scoped>
</style>

View File

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

View File

@@ -0,0 +1,482 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { Pipette } from 'lucide-vue-next'
import { computeDropdownPosition } from '../utils/dropdown'
import { useFocusTrap } from '../utils/focusTrap'
interface Props {
modelValue: string
presets?: string[]
}
const props = withDefaults(defineProps<Props>(), {
presets: () => ['#D97706', '#3B82F6', '#8B5CF6', '#EC4899', '#10B981', '#EF4444', '#06B6D4', '#6B7280'],
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
const isOpen = ref(false)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
const hexInput = ref('')
// HSV state for the gradient picker
const hue = ref(0)
const saturation = ref(100)
const brightness = ref(100)
// Canvas refs
const gradientRef = ref<HTMLCanvasElement | null>(null)
const hueRef = ref<HTMLCanvasElement | null>(null)
// Dragging state
const draggingGradient = ref(false)
const draggingHue = ref(false)
// ── Color Conversion ────────────────────────────────────────────────
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
s /= 100
v /= 100
const c = v * s
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = v - c
let r = 0, g = 0, b = 0
if (h < 60) { r = c; g = x; b = 0 }
else if (h < 120) { r = x; g = c; b = 0 }
else if (h < 180) { r = 0; g = c; b = x }
else if (h < 240) { r = 0; g = x; b = c }
else if (h < 300) { r = x; g = 0; b = c }
else { r = c; g = 0; b = x }
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255),
]
}
function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const d = max - min
let h = 0
if (d !== 0) {
if (max === r) h = 60 * (((g - b) / d) % 6)
else if (max === g) h = 60 * ((b - r) / d + 2)
else h = 60 * ((r - g) / d + 4)
}
if (h < 0) h += 360
const s = max === 0 ? 0 : (d / max) * 100
const v = max * 100
return [h, s, v]
}
function hexToRgb(hex: string): [number, number, number] | null {
const match = hex.match(/^#?([0-9a-f]{6})$/i)
if (!match) return null
const n = parseInt(match[1], 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
function rgbToHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('').toUpperCase()
}
// ── Current color ────────────────────────────────────────────────────
const currentHex = computed(() => {
const [r, g, b] = hsvToRgb(hue.value, saturation.value, brightness.value)
return rgbToHex(r, g, b)
})
// ── Sync from prop ─────────────────────────────────────────────────
function syncFromHex(hex: string) {
const rgb = hexToRgb(hex)
if (!rgb) return
const [h, s, v] = rgbToHsv(...rgb)
hue.value = h
saturation.value = s
brightness.value = v
hexInput.value = hex.toUpperCase()
}
// Initialize from prop
syncFromHex(props.modelValue || '#D97706')
watch(() => props.modelValue, (val) => {
if (val && val.toUpperCase() !== currentHex.value) {
syncFromHex(val)
}
})
// ── Emit ────────────────────────────────────────────────────────────
function emitColor() {
hexInput.value = currentHex.value
emit('update:modelValue', currentHex.value)
}
// ── Hex Input ─────────────────────────────────────────────────────
function onHexInput(e: Event) {
const val = (e.target as HTMLInputElement).value
hexInput.value = val
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
syncFromHex(val)
emit('update:modelValue', val.toUpperCase())
}
}
// ── Preset Click ──────────────────────────────────────────────────
function selectPreset(color: string) {
syncFromHex(color)
emitColor()
}
// ── Gradient Canvas (Saturation/Brightness) ────────────────────────
function drawGradient() {
const canvas = gradientRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = canvas.width
const h = canvas.height
// Base hue color
const [r, g, b] = hsvToRgb(hue.value, 100, 100)
// White to hue (horizontal)
const gradH = ctx.createLinearGradient(0, 0, w, 0)
gradH.addColorStop(0, '#FFFFFF')
gradH.addColorStop(1, `rgb(${r},${g},${b})`)
ctx.fillStyle = gradH
ctx.fillRect(0, 0, w, h)
// Black overlay (vertical)
const gradV = ctx.createLinearGradient(0, 0, 0, h)
gradV.addColorStop(0, 'rgba(0,0,0,0)')
gradV.addColorStop(1, 'rgba(0,0,0,1)')
ctx.fillStyle = gradV
ctx.fillRect(0, 0, w, h)
}
function drawHueStrip() {
const canvas = hueRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = canvas.width
const h = canvas.height
const grad = ctx.createLinearGradient(0, 0, w, 0)
for (let i = 0; i <= 360; i += 60) {
const [r, g, b] = hsvToRgb(i, 100, 100)
grad.addColorStop(i / 360, `rgb(${r},${g},${b})`)
}
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
}
// ── Gradient Pointer ────────────────────────────────────────────────
const gradientCursorX = computed(() => (saturation.value / 100) * 100)
const gradientCursorY = computed(() => ((100 - brightness.value) / 100) * 100)
const hueCursorX = computed(() => (hue.value / 360) * 100)
function handleGradientInteraction(e: MouseEvent | PointerEvent) {
const canvas = gradientRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
saturation.value = x * 100
brightness.value = (1 - y) * 100
emitColor()
}
function handleHueInteraction(e: MouseEvent | PointerEvent) {
const canvas = hueRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
hue.value = x * 360
nextTick(() => drawGradient())
emitColor()
}
function onGradientPointerDown(e: PointerEvent) {
draggingGradient.value = true
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
handleGradientInteraction(e)
}
function onGradientPointerMove(e: PointerEvent) {
if (!draggingGradient.value) return
handleGradientInteraction(e)
}
function onGradientPointerUp() {
draggingGradient.value = false
}
function onHuePointerDown(e: PointerEvent) {
draggingHue.value = true
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
handleHueInteraction(e)
}
function onHuePointerMove(e: PointerEvent) {
if (!draggingHue.value) return
handleHueInteraction(e)
}
function onHuePointerUp() {
draggingHue.value = false
}
// ── Positioning ─────────────────────────────────────────────────────
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 260, estimatedHeight: 330, panelEl: panelRef.value })
}
// ── Open / Close ────────────────────────────────────────────────────
function toggle() {
if (isOpen.value) close()
else open()
}
function open() {
syncFromHex(props.modelValue || '#D97706')
isOpen.value = true
updatePosition()
nextTick(() => {
updatePosition()
drawGradient()
drawHueStrip()
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
if (panelRef.value) activateTrap(panelRef.value)
})
}
function close() {
deactivateTrap()
isOpen.value = false
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
}
function onClickOutside(e: MouseEvent) {
const target = e.target as Node
if (triggerRef.value?.contains(target) || panelRef.value?.contains(target)) return
close()
}
function onScrollOrResize() {
if (isOpen.value) updatePosition()
}
// Redraw gradient when hue changes
watch(hue, () => {
if (isOpen.value) nextTick(() => drawGradient())
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
})
// ── Keyboard Handlers for Accessibility ─────────────────────────────
function onGradientKeydown(e: KeyboardEvent) {
const step = 5
let handled = false
if (e.key === 'ArrowRight') { saturation.value = Math.min(100, saturation.value + step); handled = true }
else if (e.key === 'ArrowLeft') { saturation.value = Math.max(0, saturation.value - step); handled = true }
else if (e.key === 'ArrowUp') { brightness.value = Math.min(100, brightness.value + step); handled = true }
else if (e.key === 'ArrowDown') { brightness.value = Math.max(0, brightness.value - step); handled = true }
if (handled) {
e.preventDefault()
emitColor()
}
}
function onHueKeydown(e: KeyboardEvent) {
const step = 5
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault()
hue.value = Math.min(360, hue.value + step)
emitColor()
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
e.preventDefault()
hue.value = Math.max(0, hue.value - step)
emitColor()
}
}
</script>
<template>
<div class="relative">
<!-- Trigger button -->
<button
ref="triggerRef"
type="button"
@click="toggle"
aria-label="Color picker"
:aria-expanded="isOpen"
aria-haspopup="dialog"
class="w-full flex items-center gap-2.5 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
:style="
isOpen
? {
borderColor: 'var(--color-accent)',
boxShadow: '0 0 0 2px var(--color-accent-muted)',
outline: 'none',
}
: {}
"
>
<span
role="img"
:aria-label="'Current color: ' + (modelValue?.toUpperCase() || '#000000')"
class="w-5 h-5 rounded-md border border-border-subtle shrink-0"
:style="{ backgroundColor: modelValue }"
/>
<span class="text-text-primary font-mono text-[0.75rem] tracking-wide">{{ modelValue?.toUpperCase() || '#000000' }}</span>
<Pipette class="w-4 h-4 text-text-secondary shrink-0 ml-auto" :stroke-width="2" aria-hidden="true" />
</button>
<!-- Color picker popover -->
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="dialog"
aria-modal="true"
aria-label="Choose color"
@keydown.escape.prevent="close"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<!-- Preset swatches -->
<div class="px-3 pt-3 pb-2">
<div class="flex gap-2 flex-wrap">
<button
v-for="c in presets"
:key="c"
type="button"
@click="selectPreset(c)"
:aria-label="'Color preset ' + c"
:aria-pressed="currentHex === c.toUpperCase()"
class="w-8 h-8 rounded-full border-2 transition-colors cursor-pointer hover:scale-110"
:class="currentHex === c.toUpperCase() ? 'border-text-primary' : 'border-transparent'"
:style="{ backgroundColor: c }"
/>
</div>
</div>
<!-- Saturation/Brightness gradient -->
<div class="px-3 pb-2">
<div
role="application"
aria-label="Saturation and brightness"
tabindex="0"
class="relative rounded-lg overflow-hidden cursor-crosshair"
style="touch-action: none;"
@pointerdown="onGradientPointerDown"
@pointermove="onGradientPointerMove"
@pointerup="onGradientPointerUp"
@keydown="onGradientKeydown"
>
<canvas
ref="gradientRef"
width="260"
height="150"
class="w-full h-[150px] block rounded-lg"
/>
<!-- Cursor -->
<div
class="absolute w-4 h-4 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)] pointer-events-none -translate-x-1/2 -translate-y-1/2"
:style="{
left: gradientCursorX + '%',
top: gradientCursorY + '%',
backgroundColor: currentHex,
}"
/>
</div>
</div>
<!-- Hue slider -->
<div class="px-3 pb-2">
<div
role="slider"
aria-label="Hue"
:aria-valuenow="Math.round(hue)"
aria-valuemin="0"
aria-valuemax="360"
tabindex="0"
class="relative rounded-md overflow-hidden cursor-pointer"
style="touch-action: none;"
@pointerdown="onHuePointerDown"
@pointermove="onHuePointerMove"
@pointerup="onHuePointerUp"
@keydown="onHueKeydown"
>
<canvas
ref="hueRef"
width="260"
height="14"
class="w-full h-3.5 block rounded-md"
/>
<!-- Hue cursor -->
<div
class="absolute top-1/2 w-3.5 h-3.5 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)] pointer-events-none -translate-x-1/2 -translate-y-1/2"
:style="{
left: hueCursorX + '%',
backgroundColor: rgbToHex(...hsvToRgb(hue, 100, 100)),
}"
/>
</div>
</div>
<!-- Hex input + preview -->
<div class="px-3 pb-3 flex items-center gap-2">
<span
role="img"
:aria-label="'Selected color: ' + currentHex"
class="w-8 h-8 rounded-lg border border-border-subtle shrink-0"
:style="{ backgroundColor: currentHex }"
/>
<input
:value="hexInput"
@input="onHexInput"
type="text"
maxlength="7"
aria-label="Hex color value"
class="flex-1 px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary font-mono tracking-wide placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="#D97706"
/>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,626 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { getLocaleCode } from '../utils/locale'
import { getFixedPositionMapping } from '../utils/dropdown'
import { useFocusTrap } from '../utils/focusTrap'
interface Props {
modelValue: string
placeholder?: string
showTime?: boolean
hour?: number
minute?: number
}
const props = withDefaults(defineProps<Props>(), {
placeholder: 'Select date',
showTime: false,
hour: 0,
minute: 0,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:hour': [value: number]
'update:minute': [value: number]
}>()
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
const isOpen = ref(false)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
// The month/year currently displayed in the calendar
const viewYear = ref(new Date().getFullYear())
const viewMonth = ref(new Date().getMonth()) // 0-indexed
// ── Formatting ──────────────────────────────────────────────────────
const displayText = computed(() => {
if (!props.modelValue) return null
const [y, m, d] = props.modelValue.split('-').map(Number)
const date = new Date(y, m - 1, d)
const datePart = date.toLocaleDateString(getLocaleCode(), {
month: 'short',
day: 'numeric',
year: 'numeric',
})
if (props.showTime) {
const hh = String(props.hour).padStart(2, '0')
const mm = String(props.minute).padStart(2, '0')
return `${datePart} ${hh}:${mm}`
}
return datePart
})
// ── Reduced motion check ────────────────────────────────────────────
const scrollBehavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' as const : 'smooth' as const
// ── Time wheel ──────────────────────────────────────────────────────
const WHEEL_ITEM_H = 36
const WHEEL_VISIBLE = 5
const WHEEL_HEIGHT = WHEEL_ITEM_H * WHEEL_VISIBLE // 180px
const WHEEL_PAD = WHEEL_ITEM_H * 2 // 72px spacer (2 items above/below center)
const internalHour = ref(props.hour)
const internalMinute = ref(props.minute)
const hourWheelRef = ref<HTMLDivElement | null>(null)
const minuteWheelRef = ref<HTMLDivElement | null>(null)
watch(() => props.hour, (v) => { internalHour.value = v })
watch(() => props.minute, (v) => { internalMinute.value = v })
// Debounced scroll handler to read the current value
let hourScrollTimer: ReturnType<typeof setTimeout> | null = null
let minuteScrollTimer: ReturnType<typeof setTimeout> | null = null
function onHourScroll() {
if (hourScrollTimer) clearTimeout(hourScrollTimer)
hourScrollTimer = setTimeout(() => {
if (!hourWheelRef.value) return
const index = Math.round(hourWheelRef.value.scrollTop / WHEEL_ITEM_H)
const clamped = Math.min(23, Math.max(0, index))
if (internalHour.value !== clamped) {
internalHour.value = clamped
emit('update:hour', clamped)
}
}, 60)
}
function onMinuteScroll() {
if (minuteScrollTimer) clearTimeout(minuteScrollTimer)
minuteScrollTimer = setTimeout(() => {
if (!minuteWheelRef.value) return
const index = Math.round(minuteWheelRef.value.scrollTop / WHEEL_ITEM_H)
const clamped = Math.min(59, Math.max(0, index))
if (internalMinute.value !== clamped) {
internalMinute.value = clamped
emit('update:minute', clamped)
}
}, 60)
}
// Mouse wheel: one item per tick
function onHourWheel(e: WheelEvent) {
e.preventDefault()
if (!hourWheelRef.value) return
const dir = e.deltaY > 0 ? 1 : -1
const cur = Math.round(hourWheelRef.value.scrollTop / WHEEL_ITEM_H)
const next = Math.min(23, Math.max(0, cur + dir))
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
function onMinuteWheel(e: WheelEvent) {
e.preventDefault()
if (!minuteWheelRef.value) return
const dir = e.deltaY > 0 ? 1 : -1
const cur = Math.round(minuteWheelRef.value.scrollTop / WHEEL_ITEM_H)
const next = Math.min(59, Math.max(0, cur + dir))
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
// Keyboard support for time wheels
function onWheelKeydown(e: KeyboardEvent, type: 'hour' | 'minute') {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
e.preventDefault()
const dir = e.key === 'ArrowUp' ? -1 : 1
if (type === 'hour') {
const next = Math.min(23, Math.max(0, internalHour.value + dir))
internalHour.value = next
emit('update:hour', next)
if (hourWheelRef.value) {
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
} else {
const next = Math.min(59, Math.max(0, internalMinute.value + dir))
internalMinute.value = next
emit('update:minute', next)
if (minuteWheelRef.value) {
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
}
}
// Click-and-drag support
let dragEl: HTMLElement | null = null
let dragStartY = 0
let dragStartScrollTop = 0
function onWheelPointerDown(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
dragEl = el
dragStartY = e.clientY
dragStartScrollTop = el.scrollTop
el.setPointerCapture(e.pointerId)
}
function onWheelPointerMove(e: PointerEvent) {
if (!dragEl) return
e.preventDefault()
const delta = dragStartY - e.clientY
dragEl.scrollTop = dragStartScrollTop + delta
}
function onWheelPointerUp(e: PointerEvent) {
if (!dragEl) return
const el = dragEl
dragEl = null
el.releasePointerCapture(e.pointerId)
// Snap to nearest item
const index = Math.round(el.scrollTop / WHEEL_ITEM_H)
el.scrollTo({ top: index * WHEEL_ITEM_H, behavior: scrollBehavior })
}
function scrollWheelsToTime() {
if (hourWheelRef.value) {
hourWheelRef.value.scrollTop = internalHour.value * WHEEL_ITEM_H
}
if (minuteWheelRef.value) {
minuteWheelRef.value.scrollTop = internalMinute.value * WHEEL_ITEM_H
}
}
const viewMonthLabel = computed(() => {
const date = new Date(viewYear.value, viewMonth.value, 1)
return date.toLocaleDateString(getLocaleCode(), { month: 'long', year: 'numeric' })
})
// ── Today helpers ───────────────────────────────────────────────────
function todayString(): string {
const now = new Date()
return formatDate(now.getFullYear(), now.getMonth(), now.getDate())
}
function formatDate(y: number, m: number, d: number): string {
const mm = String(m + 1).padStart(2, '0')
const dd = String(d).padStart(2, '0')
return `${y}-${mm}-${dd}`
}
// ── Calendar grid ───────────────────────────────────────────────────
interface DayCell {
date: number
month: number // 0-indexed
year: number
isCurrentMonth: boolean
dateString: string
}
const dayCells = computed<DayCell[]>(() => {
const y = viewYear.value
const m = viewMonth.value
const firstDayOfWeek = new Date(y, m, 1).getDay()
const startOffset = (firstDayOfWeek + 6) % 7
const daysInMonth = new Date(y, m + 1, 0).getDate()
const daysInPrevMonth = new Date(y, m, 0).getDate()
const cells: DayCell[] = []
const prevMonth = m === 0 ? 11 : m - 1
const prevYear = m === 0 ? y - 1 : y
for (let i = startOffset - 1; i >= 0; i--) {
const d = daysInPrevMonth - i
cells.push({
date: d,
month: prevMonth,
year: prevYear,
isCurrentMonth: false,
dateString: formatDate(prevYear, prevMonth, d),
})
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({
date: d,
month: m,
year: y,
isCurrentMonth: true,
dateString: formatDate(y, m, d),
})
}
const nextMonth = m === 11 ? 0 : m + 1
const nextYear = m === 11 ? y + 1 : y
let nextDay = 1
while (cells.length < 42) {
cells.push({
date: nextDay,
month: nextMonth,
year: nextYear,
isCurrentMonth: false,
dateString: formatDate(nextYear, nextMonth, nextDay),
})
nextDay++
}
return cells
})
const dayHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
// ── Positioning ─────────────────────────────────────────────────────
function updatePosition() {
if (!triggerRef.value) return
const rect = triggerRef.value.getBoundingClientRect()
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
const gap = 4
const panelWidth = props.showTime ? 390 : 280
const estW = panelWidth * scaleX
const vpW = window.innerWidth
let leftVP = rect.left
if (leftVP + estW > vpW - gap) {
leftVP = vpW - estW - gap
}
if (leftVP < gap) leftVP = gap
panelStyle.value = {
position: 'fixed',
top: `${(rect.bottom + gap - offsetY) / scaleY}px`,
left: `${(leftVP - offsetX) / scaleX}px`,
width: `${panelWidth}px`,
zIndex: '9999',
}
}
// ── Open / Close ────────────────────────────────────────────────────
function toggle() {
if (isOpen.value) {
close()
} else {
open()
}
}
function open() {
if (props.modelValue) {
const [y, m] = props.modelValue.split('-').map(Number)
viewYear.value = y
viewMonth.value = m - 1
} else {
const now = new Date()
viewYear.value = now.getFullYear()
viewMonth.value = now.getMonth()
}
isOpen.value = true
updatePosition()
nextTick(() => {
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
if (props.showTime) {
scrollWheelsToTime()
}
if (panelRef.value) activateTrap(panelRef.value)
})
}
function close() {
deactivateTrap()
isOpen.value = false
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
}
// ── Month navigation ────────────────────────────────────────────────
function prevMonthNav() {
if (viewMonth.value === 0) {
viewMonth.value = 11
viewYear.value--
} else {
viewMonth.value--
}
}
function nextMonthNav() {
if (viewMonth.value === 11) {
viewMonth.value = 0
viewYear.value++
} else {
viewMonth.value++
}
}
// ── Selection ───────────────────────────────────────────────────────
function selectDay(cell: DayCell) {
if (!cell.isCurrentMonth) return
emit('update:modelValue', cell.dateString)
if (!props.showTime) {
close()
}
}
function selectToday() {
emit('update:modelValue', todayString())
if (!props.showTime) {
close()
}
}
// ── Event handlers ──────────────────────────────────────────────────
function onClickOutside(e: MouseEvent) {
const target = e.target as Node
if (
triggerRef.value?.contains(target) ||
panelRef.value?.contains(target)
) {
return
}
close()
}
function onScrollOrResize() {
if (isOpen.value) {
updatePosition()
}
}
// ── Sync view when modelValue changes externally ────────────────────
watch(
() => props.modelValue,
(val) => {
if (val && isOpen.value) {
const [y, m] = val.split('-').map(Number)
viewYear.value = y
viewMonth.value = m - 1
}
}
)
// ── Cleanup ─────────────────────────────────────────────────────────
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
})
</script>
<template>
<div class="relative">
<!-- Trigger button -->
<button
ref="triggerRef"
type="button"
@click="toggle"
:aria-expanded="isOpen"
aria-haspopup="dialog"
class="w-full flex items-center justify-between gap-2 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
:style="
isOpen
? {
borderColor: 'var(--color-accent)',
boxShadow: '0 0 0 2px var(--color-accent-muted)',
outline: 'none',
}
: {}
"
>
<span
:class="displayText ? 'text-text-primary' : 'text-text-tertiary'"
class="truncate"
>
{{ displayText ?? placeholder }}
</span>
<Calendar
aria-hidden="true"
class="w-4 h-4 text-text-secondary shrink-0"
:stroke-width="2"
/>
</button>
<!-- Calendar popover -->
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="dialog"
aria-modal="true"
aria-label="Date picker"
@keydown.escape.prevent="close"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<!-- Month/year header -->
<div class="flex items-center justify-between px-3 py-2.5">
<button
type="button"
@click="prevMonthNav"
aria-label="Previous month"
v-tooltip="'Previous month'"
class="p-1 rounded-lg hover:bg-bg-elevated transition-colors cursor-pointer text-text-secondary hover:text-text-primary"
>
<ChevronLeft aria-hidden="true" class="w-4 h-4" :stroke-width="2" />
</button>
<span class="text-[0.8125rem] font-medium text-text-primary select-none">
{{ viewMonthLabel }}
</span>
<button
type="button"
@click="nextMonthNav"
aria-label="Next month"
v-tooltip="'Next month'"
class="p-1 rounded-lg hover:bg-bg-elevated transition-colors cursor-pointer text-text-secondary hover:text-text-primary"
>
<ChevronRight aria-hidden="true" class="w-4 h-4" :stroke-width="2" />
</button>
</div>
<!-- Calendar + Time wheels side by side -->
<div class="flex">
<!-- Calendar column -->
<div class="flex-1 min-w-0">
<!-- Day-of-week headers -->
<div class="grid grid-cols-7 px-2" role="row">
<div
v-for="header in dayHeaders"
:key="header"
role="columnheader"
class="text-center text-[0.6875rem] font-medium text-text-tertiary py-1 select-none"
>
{{ header }}
</div>
</div>
<!-- Day grid -->
<div class="grid grid-cols-7 px-2 pb-2" role="grid" aria-label="Calendar days">
<button
v-for="(cell, index) in dayCells"
:key="index"
type="button"
:disabled="!cell.isCurrentMonth"
@click="selectDay(cell)"
:aria-label="new Date(cell.year, cell.month, cell.date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })"
:aria-selected="cell.isCurrentMonth ? cell.dateString === modelValue : undefined"
class="relative flex items-center justify-center h-8 w-full text-[0.75rem] rounded-lg transition-colors select-none"
:class="[
!cell.isCurrentMonth
? 'text-text-tertiary/40 cursor-default'
: cell.dateString === modelValue
? 'bg-accent text-bg-base font-medium cursor-pointer'
: cell.dateString === todayString()
? 'ring-1 ring-accent text-accent-text cursor-pointer hover:bg-bg-elevated'
: 'text-text-primary cursor-pointer hover:bg-bg-elevated',
]"
>
{{ cell.date }}
</button>
</div>
</div>
<!-- Time wheels column (to the right of calendar) -->
<div v-if="showTime" class="border-l border-border-subtle flex flex-col items-center justify-center px-3">
<div class="flex items-center gap-1.5">
<!-- Hour wheel -->
<div
class="relative overflow-hidden rounded-lg"
:style="{ height: WHEEL_HEIGHT + 'px', width: '42px' }"
>
<!-- Highlight band (behind scroll content via DOM order) -->
<div
class="absolute inset-x-0 rounded-lg pointer-events-none bg-bg-elevated border border-border-subtle"
:style="{ top: WHEEL_PAD + 'px', height: WHEEL_ITEM_H + 'px' }"
/>
<!-- Scrollable wheel -->
<div
ref="hourWheelRef"
tabindex="0"
role="spinbutton"
:aria-valuenow="internalHour"
aria-valuemin="0"
aria-valuemax="23"
aria-label="Hour"
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
@scroll="onHourScroll"
@wheel.prevent="onHourWheel"
@keydown="onWheelKeydown($event, 'hour')"
@pointerdown.prevent="onWheelPointerDown"
@pointermove="onWheelPointerMove"
@pointerup="onWheelPointerUp"
>
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
<div
v-for="h in 24"
:key="h"
class="shrink-0 flex items-center justify-center text-[0.875rem] font-mono select-none"
:style="{ height: WHEEL_ITEM_H + 'px' }"
:class="internalHour === h - 1 ? 'text-text-primary font-semibold' : 'text-text-tertiary'"
>
{{ String(h - 1).padStart(2, '0') }}
</div>
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
</div>
</div>
<span class="text-text-secondary text-sm font-mono font-semibold select-none">:</span>
<!-- Minute wheel -->
<div
class="relative overflow-hidden rounded-lg"
:style="{ height: WHEEL_HEIGHT + 'px', width: '42px' }"
>
<!-- Highlight band -->
<div
class="absolute inset-x-0 rounded-lg pointer-events-none bg-bg-elevated border border-border-subtle"
:style="{ top: WHEEL_PAD + 'px', height: WHEEL_ITEM_H + 'px' }"
/>
<!-- Scrollable wheel -->
<div
ref="minuteWheelRef"
tabindex="0"
role="spinbutton"
:aria-valuenow="internalMinute"
aria-valuemin="0"
aria-valuemax="59"
aria-label="Minute"
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
@scroll="onMinuteScroll"
@wheel.prevent="onMinuteWheel"
@keydown="onWheelKeydown($event, 'minute')"
@pointerdown.prevent="onWheelPointerDown"
@pointermove="onWheelPointerMove"
@pointerup="onWheelPointerUp"
>
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
<div
v-for="m in 60"
:key="m"
class="shrink-0 flex items-center justify-center text-[0.875rem] font-mono select-none"
:style="{ height: WHEEL_ITEM_H + 'px' }"
:class="internalMinute === m - 1 ? 'text-text-primary font-semibold' : 'text-text-tertiary'"
>
{{ String(m - 1).padStart(2, '0') }}
</div>
<div :style="{ height: WHEEL_PAD + 'px' }" class="shrink-0" />
</div>
</div>
</div>
</div>
</div>
<!-- Today shortcut -->
<div class="border-t border-border-subtle px-3 py-2">
<button
type="button"
@click="selectToday"
class="w-full text-center text-[0.75rem] text-accent-text hover:text-accent cursor-pointer transition-colors py-0.5"
>
Today
</button>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>

View File

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

View File

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

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Minus, Plus } from 'lucide-vue-next'
interface Props {
modelValue: number
min?: number
max?: number
step?: number
precision?: number
prefix?: string
suffix?: string
label?: string
compact?: boolean
}
const props = withDefaults(defineProps<Props>(), {
min: 0,
max: Infinity,
step: 1,
precision: 0,
prefix: '',
suffix: '',
label: 'Number input',
compact: false,
})
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const isEditing = ref(false)
const editValue = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
const displayValue = computed(() => {
return props.modelValue.toFixed(props.precision)
})
function setValue(val: number) {
const clamped = Math.min(props.max, Math.max(props.min, val))
const rounded = parseFloat(clamped.toFixed(props.precision))
emit('update:modelValue', rounded)
}
function increment() {
setValue(props.modelValue + props.step)
}
function decrement() {
setValue(props.modelValue - props.step)
}
// ── Press-and-hold ───────────────────────────────────────────────
let holdTimeout: ReturnType<typeof setTimeout> | null = null
let holdInterval: ReturnType<typeof setInterval> | null = null
function startHold(action: () => void) {
action()
holdTimeout = setTimeout(() => {
holdInterval = setInterval(action, 80)
}, 400)
}
function stopHold() {
if (holdTimeout) { clearTimeout(holdTimeout); holdTimeout = null }
if (holdInterval) { clearInterval(holdInterval); holdInterval = null }
}
// ── Inline editing ───────────────────────────────────────────────
function startEdit() {
isEditing.value = true
editValue.value = displayValue.value
setTimeout(() => inputRef.value?.select(), 0)
}
function commitEdit() {
isEditing.value = false
const parsed = parseFloat(editValue.value)
if (!isNaN(parsed)) {
setValue(parsed)
}
}
function cancelEdit() {
isEditing.value = false
}
</script>
<template>
<div
class="flex items-center"
:class="compact ? 'gap-1' : 'gap-2'"
role="group"
:aria-label="label"
>
<button
type="button"
aria-label="Decrease value"
v-tooltip="'Decrease'"
@mousedown.prevent="startHold(decrement)"
@mouseup="stopHold"
@mouseleave="stopHold"
@touchstart.prevent="startHold(decrement)"
@touchend="stopHold"
class="flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
:class="compact ? 'w-6 h-6' : 'w-8 h-8'"
:disabled="modelValue <= min"
>
<Minus :class="compact ? 'w-3 h-3' : 'w-3.5 h-3.5'" aria-hidden="true" :stroke-width="2" />
</button>
<div
v-if="!isEditing"
@click="startEdit"
@keydown.enter="startEdit"
@keydown.space.prevent="startEdit"
tabindex="0"
role="button"
:aria-label="'Edit value: ' + displayValue"
class="text-center font-mono text-text-primary cursor-text select-none"
:class="compact ? 'min-w-[2.5rem] text-[0.75rem]' : 'min-w-[4rem] text-[0.8125rem]'"
aria-live="polite"
>
<span v-if="prefix" class="text-text-tertiary">{{ prefix }}</span>
{{ displayValue }}
<span v-if="suffix" class="text-text-tertiary ml-0.5">{{ suffix }}</span>
</div>
<input
v-else
ref="inputRef"
v-model="editValue"
type="text"
inputmode="decimal"
:aria-label="label"
class="text-center px-1 py-0.5 bg-bg-inset border border-accent rounded-lg font-mono text-text-primary focus:outline-none"
:class="compact ? 'w-16 text-[0.75rem]' : 'w-20 text-[0.8125rem]'"
@blur="commitEdit"
@keydown.enter="commitEdit"
@keydown.escape="cancelEdit"
/>
<button
type="button"
aria-label="Increase value"
v-tooltip="'Increase'"
@mousedown.prevent="startHold(increment)"
@mouseup="stopHold"
@mouseleave="stopHold"
@touchstart.prevent="startHold(increment)"
@touchend="stopHold"
class="flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
:class="compact ? 'w-6 h-6' : 'w-8 h-8'"
:disabled="modelValue >= max"
>
<Plus :class="compact ? 'w-3 h-3' : 'w-3.5 h-3.5'" aria-hidden="true" :stroke-width="2" />
</button>
</div>
</template>

View File

@@ -0,0 +1,322 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ChevronDown, Check } from 'lucide-vue-next'
import { computeDropdownPosition } from '../utils/dropdown'
interface Props {
modelValue: any
options: any[]
labelKey?: string
valueKey?: string
placeholder?: string
disabled?: boolean
placeholderValue?: any
searchable?: boolean
ariaLabelledby?: string
}
const props = withDefaults(defineProps<Props>(), {
labelKey: 'name',
valueKey: 'id',
placeholder: 'Select...',
disabled: false,
placeholderValue: undefined,
searchable: false,
})
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const listboxId = 'appselect-lb-' + Math.random().toString(36).slice(2, 9)
const isOpen = ref(false)
const highlightedIndex = ref(-1)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
const searchQuery = ref('')
const searchInputRef = ref<HTMLInputElement | null>(null)
// Build the full list: placeholder + real options
const allItems = computed(() => {
const placeholderItem = {
_isPlaceholder: true,
[props.valueKey]: props.placeholderValue,
[props.labelKey]: props.placeholder,
}
return [placeholderItem, ...props.options]
})
const filteredItems = computed(() => {
if (!props.searchable || !searchQuery.value) return allItems.value
const q = searchQuery.value.toLowerCase()
return allItems.value.filter(item => {
if (item._isPlaceholder) return true
return getOptionLabel(item).toLowerCase().includes(q)
})
})
const selectedLabel = computed(() => {
const option = props.options.find(
(o) => o[props.valueKey] === props.modelValue
)
return option ? option[props.labelKey] : null
})
const isPlaceholderSelected = computed(() => {
return selectedLabel.value === null
})
function getOptionValue(item: any): any {
return item._isPlaceholder ? props.placeholderValue : item[props.valueKey]
}
function getOptionLabel(item: any): string {
return item[props.labelKey]
}
function isSelected(item: any): boolean {
const val = getOptionValue(item)
return val === props.modelValue
}
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, {
estimatedHeight: 280,
panelEl: panelRef.value,
})
}
function toggle() {
if (props.disabled) return
if (isOpen.value) {
close()
} else {
open()
}
}
function open() {
if (props.disabled) return
isOpen.value = true
updatePosition()
// Set highlighted index to the currently selected item
const selectedIdx = allItems.value.findIndex((item) => isSelected(item))
highlightedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
if (props.searchable) {
searchQuery.value = ''
nextTick(() => searchInputRef.value?.focus())
}
nextTick(() => {
scrollHighlightedIntoView()
// Reposition with actual panel height (fixes above-flip offset)
updatePosition()
})
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
}
function close() {
isOpen.value = false
highlightedIndex.value = -1
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
}
function select(item: any) {
emit('update:modelValue', getOptionValue(item))
close()
}
function onClickOutside(e: MouseEvent) {
const target = e.target as Node
if (
triggerRef.value?.contains(target) ||
panelRef.value?.contains(target)
) {
return
}
close()
}
function onScrollOrResize() {
if (isOpen.value) {
updatePosition()
}
}
function scrollHighlightedIntoView() {
if (!panelRef.value) return
const items = panelRef.value.querySelectorAll('[data-option]')
const item = items[highlightedIndex.value] as HTMLElement | undefined
if (item) {
item.scrollIntoView({ block: 'nearest' })
}
}
function onKeydown(e: KeyboardEvent) {
if (!isOpen.value) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
open()
return
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
filteredItems.value.length - 1
)
nextTick(() => scrollHighlightedIntoView())
break
case 'ArrowUp':
e.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
nextTick(() => scrollHighlightedIntoView())
break
case 'Enter':
case ' ':
e.preventDefault()
if (highlightedIndex.value >= 0) {
select(filteredItems.value[highlightedIndex.value])
}
break
case 'Escape':
e.preventDefault()
close()
triggerRef.value?.focus()
break
case 'Tab':
close()
break
}
}
function onSearchKeydown(e: KeyboardEvent) {
if (['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key)) {
onKeydown(e)
}
}
onMounted(() => {
// Nothing needed on mount since listeners are added when opened
})
onBeforeUnmount(() => {
// Clean up all listeners in case component is destroyed while open
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
})
</script>
<template>
<div class="relative">
<!-- Trigger button -->
<button
ref="triggerRef"
type="button"
role="combobox"
:aria-expanded="isOpen"
aria-haspopup="listbox"
:aria-activedescendant="isOpen && highlightedIndex >= 0 ? 'appselect-option-' + highlightedIndex : undefined"
:aria-labelledby="ariaLabelledby"
:aria-controls="isOpen ? listboxId : undefined"
:disabled="disabled"
@click="toggle"
@keydown="onKeydown"
class="w-full flex items-center justify-between gap-2 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left transition-colors"
:class="{
'opacity-40 cursor-not-allowed': disabled,
'cursor-pointer': !disabled,
}"
:style="
isOpen
? {
borderColor: 'var(--color-accent)',
boxShadow: '0 0 0 2px var(--color-accent-muted)',
outline: 'none',
}
: {}
"
>
<slot name="selected" :label="selectedLabel ?? placeholder" :is-placeholder="isPlaceholderSelected">
<span
:class="isPlaceholderSelected ? 'text-text-tertiary' : 'text-text-primary'"
class="truncate"
>
{{ selectedLabel ?? placeholder }}
</span>
</slot>
<ChevronDown
aria-hidden="true"
class="w-4 h-4 text-text-secondary shrink-0 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"
:stroke-width="2"
/>
</button>
<!-- Dropdown panel -->
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<div v-if="searchable" class="px-2 pt-2 pb-1">
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
aria-label="Search options"
class="w-full px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="Search..."
@keydown="onSearchKeydown"
/>
</div>
<div class="max-h-[240px] overflow-y-auto py-1" role="listbox" :id="listboxId">
<div
v-for="(item, index) in filteredItems"
:key="item._isPlaceholder ? '__placeholder__' : item[valueKey]"
role="option"
:id="'appselect-option-' + index"
:aria-selected="isSelected(item)"
data-option
@click="select(item)"
@mouseenter="highlightedIndex = index"
class="flex items-center justify-between gap-2 px-3 py-2 text-[0.8125rem] cursor-pointer transition-colors"
:class="{
'bg-bg-elevated': highlightedIndex === index,
'text-text-tertiary': item._isPlaceholder,
'text-text-primary': !item._isPlaceholder,
}"
>
<slot name="option" :item="item" :label="getOptionLabel(item)" :selected="isSelected(item)">
<span class="truncate">{{ getOptionLabel(item) }}</span>
</slot>
<Check
v-if="isSelected(item)"
aria-hidden="true"
class="w-4 h-4 text-accent shrink-0"
:stroke-width="2"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>

View File

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

View File

@@ -0,0 +1,219 @@
<script setup lang="ts">
import { ref, computed, onBeforeUnmount, nextTick } from 'vue'
import { X, Plus } from 'lucide-vue-next'
import { useTagsStore } from '../stores/tags'
import { computeDropdownPosition } from '../utils/dropdown'
interface Props {
modelValue: number[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: number[]]
}>()
const tagsStore = useTagsStore()
const isOpen = ref(false)
const searchQuery = ref('')
const triggerRef = ref<HTMLDivElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
const inputRef = ref<HTMLInputElement | null>(null)
const highlightedIndex = ref(-1)
const selectedTags = computed(() => {
return tagsStore.tags.filter(t => t.id && props.modelValue.includes(t.id))
})
const filteredTags = computed(() => {
const q = searchQuery.value.toLowerCase()
return tagsStore.tags.filter(t => {
if (t.id && props.modelValue.includes(t.id)) return false
if (q && !t.name.toLowerCase().includes(q)) return false
return true
})
})
const showCreateOption = computed(() => {
if (!searchQuery.value.trim()) return false
return !tagsStore.tags.some(t => t.name.toLowerCase() === searchQuery.value.trim().toLowerCase())
})
function toggleTag(tagId: number) {
const current = [...props.modelValue]
const index = current.indexOf(tagId)
if (index >= 0) {
current.splice(index, 1)
} else {
current.push(tagId)
}
emit('update:modelValue', current)
}
function removeTag(tagId: number) {
emit('update:modelValue', props.modelValue.filter(id => id !== tagId))
}
async function createAndAdd() {
const name = searchQuery.value.trim()
if (!name) return
const colors = ['#3B82F6', '#8B5CF6', '#EC4899', '#10B981', '#EF4444', '#06B6D4', '#F59E0B', '#6B7280']
const color = colors[tagsStore.tags.length % colors.length]
const id = await tagsStore.createTag({ name, color })
if (id) {
emit('update:modelValue', [...props.modelValue, id])
searchQuery.value = ''
}
}
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 200, estimatedHeight: 200, panelEl: panelRef.value })
}
function open() {
isOpen.value = true
searchQuery.value = ''
updatePosition()
nextTick(() => {
updatePosition()
inputRef.value?.focus()
})
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
}
function close() {
isOpen.value = false
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
}
function onClickOutside(e: MouseEvent) {
const target = e.target as Node
if (triggerRef.value?.contains(target) || panelRef.value?.contains(target)) return
close()
}
function onScrollOrResize() {
if (isOpen.value) updatePosition()
}
function onSearchKeydown(e: KeyboardEvent) {
const total = filteredTags.value.length + (showCreateOption.value ? 1 : 0)
if (e.key === 'ArrowDown') {
e.preventDefault()
highlightedIndex.value = Math.min(highlightedIndex.value + 1, total - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1)
} else if (e.key === 'Enter' && highlightedIndex.value >= 0) {
e.preventDefault()
if (highlightedIndex.value < filteredTags.value.length) {
const tag = filteredTags.value[highlightedIndex.value]
toggleTag(tag.id!)
searchQuery.value = ''
} else if (showCreateOption.value) {
createAndAdd()
}
highlightedIndex.value = -1
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
})
</script>
<template>
<div ref="triggerRef" class="relative">
<!-- Selected tags + add button -->
<TransitionGroup tag="div" name="chip" class="flex flex-wrap items-center gap-1.5">
<span
v-for="tag in selectedTags"
:key="tag.id"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[0.6875rem] text-text-primary"
:style="{ backgroundColor: tag.color + '22', borderColor: tag.color + '44', border: '1px solid' }"
>
<span class="w-1.5 h-1.5 rounded-full" :style="{ backgroundColor: tag.color }" aria-hidden="true" />
{{ tag.name }}
<button @click.stop="removeTag(tag.id!)" :aria-label="'Remove tag ' + tag.name" v-tooltip="'Remove tag'" class="ml-0.5 min-w-[24px] min-h-[24px] flex items-center justify-center hover:text-status-error">
<X class="w-2.5 h-2.5" aria-hidden="true" />
</button>
</span>
<button
key="__add_btn__"
type="button"
@click="isOpen ? close() : open()"
aria-label="Add tag"
:aria-expanded="isOpen"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[0.6875rem] text-text-tertiary border border-border-subtle hover:text-text-secondary hover:border-border-visible transition-colors"
>
<Plus class="w-3 h-3" aria-hidden="true" />
Tag
</button>
</TransitionGroup>
<!-- Dropdown -->
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="listbox"
aria-label="Tag options"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<div class="px-2 pt-2 pb-1">
<input
ref="inputRef"
v-model="searchQuery"
type="text"
aria-label="Search or create tag"
@keydown="onSearchKeydown"
class="w-full px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="Search or create tag..."
/>
</div>
<div class="max-h-[160px] overflow-y-auto py-1">
<div
v-for="(tag, index) in filteredTags"
:key="tag.id"
:id="'tag-option-' + tag.id"
role="option"
@click="toggleTag(tag.id!); searchQuery = ''"
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-bg-elevated transition-colors"
:class="{ 'bg-bg-elevated': index === highlightedIndex }"
>
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: tag.color }" aria-hidden="true" />
<span class="text-[0.75rem] text-text-primary">{{ tag.name }}</span>
</div>
<div
v-if="showCreateOption"
role="option"
@click="createAndAdd"
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-bg-elevated transition-colors text-accent-text"
:class="{ 'bg-bg-elevated': filteredTags.length === highlightedIndex }"
>
<Plus class="w-3 h-3" aria-hidden="true" />
<span class="text-[0.75rem]">Create "{{ searchQuery.trim() }}"</span>
</div>
<div v-if="filteredTags.length === 0 && !showCreateOption" class="px-3 py-3 text-center text-[0.75rem] text-text-tertiary">
No tags found
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,240 @@
<script setup lang="ts">
import { ref, watch, nextTick, onUnmounted } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
import { useAnnouncer } from '../composables/useAnnouncer'
import { useEntryTemplatesStore, type EntryTemplate } from '../stores/entryTemplates'
import { useProjectsStore } from '../stores/projects'
import { X, FileText, Pencil, Trash2 } from 'lucide-vue-next'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
select: [template: EntryTemplate]
cancel: []
}>()
const templatesStore = useEntryTemplatesStore()
const projectsStore = useProjectsStore()
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
const { announce } = useAnnouncer()
const dialogRef = ref<HTMLElement | null>(null)
const activeIndex = ref(0)
function getProjectName(projectId: number): string {
return projectsStore.projects.find(p => p.id === projectId)?.name || 'Unknown'
}
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`
}
watch(() => props.show, async (val) => {
if (val) {
activeIndex.value = 0
await templatesStore.fetchTemplates()
await nextTick()
if (dialogRef.value) {
activateTrap(dialogRef.value, { onDeactivate: () => emit('cancel') })
}
announce(`Template picker opened. ${templatesStore.templates.length} templates available.`)
} else {
deactivateTrap()
}
})
onUnmounted(() => deactivateTrap())
function onKeydown(e: KeyboardEvent) {
const len = templatesStore.templates.length
if (!len) return
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % len
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = (activeIndex.value - 1 + len) % len
} else if (e.key === 'Enter') {
e.preventDefault()
emit('select', templatesStore.templates[activeIndex.value])
}
}
function selectTemplate(tpl: EntryTemplate) {
emit('select', tpl)
}
const editingId = ref<number | null>(null)
const editForm = ref({ name: '', project_id: 0, duration: 0 })
const confirmDeleteId = ref<number | null>(null)
function startEdit(tpl: EntryTemplate) {
editingId.value = tpl.id!
editForm.value = { name: tpl.name, project_id: tpl.project_id, duration: tpl.duration || 0 }
confirmDeleteId.value = null
}
function cancelEdit() {
editingId.value = null
}
async function saveEdit(tpl: EntryTemplate) {
await templatesStore.updateTemplate({
...tpl,
name: editForm.value.name,
project_id: editForm.value.project_id,
duration: editForm.value.duration,
})
editingId.value = null
announce('Template updated')
}
function confirmDelete(id: number) {
confirmDeleteId.value = id
editingId.value = null
}
async function executeDelete(id: number) {
await templatesStore.deleteTemplate(id)
confirmDeleteId.value = null
announce('Template deleted')
}
</script>
<template>
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="$emit('cancel')"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-labelledby="template-picker-title"
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6"
@keydown="onKeydown"
>
<div class="flex items-center justify-between mb-4">
<h2 id="template-picker-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary">
From Template
</h2>
<button
@click="$emit('cancel')"
class="p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close"
v-tooltip="'Close'"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div v-if="templatesStore.templates.length > 0" class="space-y-1 max-h-64 overflow-y-auto" role="listbox" aria-label="Entry templates">
<div
v-for="(tpl, i) in templatesStore.templates"
:key="tpl.id"
>
<!-- Delete confirmation -->
<div v-if="confirmDeleteId === tpl.id" class="px-3 py-2 rounded-lg bg-status-error/10 border border-status-error/20">
<p class="text-[0.8125rem] text-text-primary mb-2">Delete this template?</p>
<div class="flex gap-2">
<button
@click="executeDelete(tpl.id!)"
class="px-3 py-1.5 text-[0.75rem] font-medium bg-status-error text-white rounded-lg hover:bg-status-error/90 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Delete
</button>
<button
@click="confirmDeleteId = null"
class="px-3 py-1.5 text-[0.75rem] font-medium text-text-secondary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Cancel
</button>
</div>
</div>
<!-- Edit mode -->
<div v-else-if="editingId === tpl.id" class="px-3 py-2 rounded-lg bg-bg-elevated space-y-2">
<input
v-model="editForm.name"
class="w-full px-2 py-1.5 text-[0.8125rem] bg-bg-base border border-border-subtle rounded-lg text-text-primary focus:outline-2 focus:outline-accent"
placeholder="Template name"
/>
<select
v-model="editForm.project_id"
class="w-full px-2 py-1.5 text-[0.8125rem] bg-bg-base border border-border-subtle rounded-lg text-text-primary focus:outline-2 focus:outline-accent"
>
<option v-for="p in projectsStore.activeProjects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<div class="flex gap-2">
<button
@click="saveEdit(tpl)"
class="px-3 py-1.5 text-[0.75rem] font-medium bg-accent text-white rounded-lg hover:bg-accent/90 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Save
</button>
<button
@click="cancelEdit"
class="px-3 py-1.5 text-[0.75rem] font-medium text-text-secondary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Cancel
</button>
</div>
</div>
<!-- Normal display -->
<div v-else class="flex items-center group">
<button
@click="selectTemplate(tpl)"
role="option"
:aria-selected="i === activeIndex"
:class="[
'flex-1 text-left px-3 py-2 rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
i === activeIndex ? 'bg-accent-muted' : 'hover:bg-bg-elevated'
]"
>
<p class="text-[0.8125rem] text-text-primary">{{ tpl.name }}</p>
<p class="text-[0.6875rem] text-text-tertiary">
{{ getProjectName(tpl.project_id) }}
<span v-if="tpl.duration"> - {{ formatDuration(tpl.duration) }}</span>
</p>
</button>
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 pr-1">
<button
@click.stop="startEdit(tpl)"
class="p-1.5 text-text-tertiary hover:text-text-primary transition-colors rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Edit template"
v-tooltip="'Edit'"
>
<Pencil class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="1.5" />
</button>
<button
@click.stop="confirmDelete(tpl.id!)"
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Delete template"
v-tooltip="'Delete'"
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="1.5" />
</button>
</div>
</div>
</div>
</div>
<div v-else class="py-8 text-center">
<FileText class="w-8 h-8 text-text-tertiary mx-auto mb-2" :stroke-width="1.5" aria-hidden="true" />
<p class="text-[0.8125rem] text-text-secondary">No templates saved yet</p>
<p class="text-[0.6875rem] text-text-tertiary mt-1">Use "Save as Template" when editing an entry</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { computed } from 'vue'
import InvoicePreview from './InvoicePreview.vue'
import {
TEMPLATE_CATEGORIES,
getTemplatesByCategory,
getTemplateById,
} from '../utils/invoiceTemplates'
import type { InvoiceItem } from '../utils/invoicePdf'
import type { BusinessInfo } from '../utils/invoicePdfRenderer'
import type { Invoice } from '../stores/invoices'
import type { Client } from '../stores/clients'
const props = withDefaults(
defineProps<{
modelValue: string
invoice?: Invoice
client?: Client | null
items?: InvoiceItem[]
businessInfo?: BusinessInfo
}>(),
{
invoice: undefined,
client: undefined,
items: undefined,
businessInfo: undefined,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// ---------------------------------------------------------------------------
// Default sample data
// ---------------------------------------------------------------------------
const defaultInvoice: Invoice = {
client_id: 0,
invoice_number: 'INV-2026-001',
date: '2026-02-18',
due_date: '2026-03-18',
subtotal: 8800,
tax_rate: 10,
tax_amount: 880,
discount: 0,
total: 9680,
notes: 'Payment due within 30 days. Thank you for your business!',
status: 'pending',
}
const defaultClient: Client = {
id: 1,
name: 'Acme Corporation',
email: 'billing@acme.com',
address: '123 Business Ave\nSuite 100\nNew York, NY 10001',
}
const defaultBusinessInfo: BusinessInfo = {
name: 'Your Business Name',
address: '456 Creative St, Design City',
email: 'hello@business.com',
phone: '(555) 123-4567',
logo: '',
}
// ---------------------------------------------------------------------------
// Computed
// ---------------------------------------------------------------------------
const selectedTemplate = computed(() => getTemplateById(props.modelValue))
const previewInvoice = computed(() => props.invoice ?? defaultInvoice)
const previewClient = computed(() =>
props.client !== undefined ? props.client : defaultClient,
)
const previewItems = computed(() =>
props.items && props.items.length > 0 ? props.items : [],
)
const previewBusinessInfo = computed(() => props.businessInfo ?? defaultBusinessInfo)
function selectTemplate(id: string) {
emit('update:modelValue', id)
}
</script>
<template>
<div
class="flex border border-border-subtle rounded-lg overflow-hidden"
style="height: 480px"
>
<!-- Left panel: Template list -->
<div class="w-[30%] border-r border-border-subtle overflow-y-auto bg-bg-surface" role="radiogroup" aria-label="Invoice templates">
<div v-for="cat in TEMPLATE_CATEGORIES" :key="cat.id">
<div
class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.08em] font-medium px-3 pt-3 pb-1"
>
{{ cat.label }}
</div>
<button
v-for="tmpl in getTemplatesByCategory(cat.id)"
:key="tmpl.id"
role="radio"
:aria-checked="tmpl.id === modelValue"
class="w-full flex items-center gap-2 px-3 py-1.5 text-[0.75rem] transition-colors"
:class="
tmpl.id === modelValue
? 'bg-accent/10 text-accent-text'
: 'text-text-secondary hover:bg-bg-elevated hover:text-text-primary'
"
@click="selectTemplate(tmpl.id)"
>
<span
class="w-2.5 h-2.5 rounded-full shrink-0 border border-black/10"
:style="{ backgroundColor: tmpl.colors.primary }"
aria-hidden="true"
/>
<span class="truncate">{{ tmpl.name }}</span>
</button>
</div>
</div>
<!-- Right panel: Live preview -->
<div class="w-[70%] bg-bg-inset p-4 flex items-start justify-center overflow-y-auto" aria-label="Template preview" aria-live="polite">
<div class="w-full max-w-sm">
<InvoicePreview
:template="selectedTemplate"
:invoice="previewInvoice"
:client="previewClient"
:items="previewItems"
:business-info="previewBusinessInfo"
/>
</div>
</div>
</div>
</template>

View File

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

Some files were not shown because too many files have changed in this diff Show More