Compare commits
125 Commits
ae02cba958
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb0c65c29a | ||
|
|
a0bb7d3ea8 | ||
|
|
514090eed4 | ||
|
|
2608f447de | ||
|
|
5300ceeb12 | ||
|
|
875d3ca23b | ||
|
|
fa7b70aa61 | ||
|
|
773ba1d338 | ||
|
|
a41ce44f13 | ||
|
|
ace66a6093 | ||
|
|
3fd389da11 | ||
|
|
159bb927af | ||
|
|
29f29e3368 | ||
|
|
991866f017 | ||
|
|
d4fa17d315 | ||
|
|
cb1c6c9b5d | ||
|
|
a3ea37baa1 | ||
|
|
54f75c15ed | ||
|
|
14c45c67e5 | ||
|
|
15c8db6572 | ||
|
|
dcfcdaf1b0 | ||
|
|
6e05ddcf89 | ||
|
|
529461f12c | ||
|
|
fb38d98612 | ||
|
|
115bdd33db | ||
|
|
4589fea5ce | ||
|
|
522efbf230 | ||
|
|
43bd3b9b41 | ||
|
|
35e97cbe7b | ||
|
|
25c6c55eb2 | ||
|
|
1f21cd61c3 | ||
|
|
3968a818c5 | ||
|
|
85b39e41f6 | ||
|
|
24b3caf0da | ||
|
|
6ed462853c | ||
|
|
dea742707f | ||
|
|
a3c0d43f67 | ||
|
|
78026c8bf0 | ||
|
|
b8239c6e1b | ||
|
|
4462d832d2 | ||
|
|
edccc12c34 | ||
|
|
5680194ef4 | ||
|
|
08d61b40a0 | ||
|
|
6e00b8b8a3 | ||
|
|
f46424141d | ||
|
|
fd4cc29d53 | ||
|
|
f40cc97668 | ||
|
|
a313477cd7 | ||
|
|
c5380568ca | ||
|
|
886a2b100e | ||
|
|
cca06e851b | ||
|
|
9e9c0c78f1 | ||
|
|
5e47700c93 | ||
|
|
162acccc2c | ||
|
|
06dc063125 | ||
|
|
3cadb42f8b | ||
|
|
673da2aab8 | ||
|
|
3928904c40 | ||
|
|
f1a5428dd5 | ||
|
|
50734dee03 | ||
|
|
a527a5ceca | ||
|
|
32ee6284da | ||
|
|
04d4220604 | ||
|
|
bd0dbaf91d | ||
|
|
5630751adc | ||
|
|
6b7dcc7317 | ||
|
|
c7b9822e48 | ||
|
|
87b1853f39 | ||
|
|
787f8bbacf | ||
|
|
7e7e04e4d4 | ||
|
|
f6955d1bd7 | ||
|
|
af95a53c4e | ||
|
|
e143b069db | ||
|
|
28d199bddc | ||
|
|
b650e981fc | ||
|
|
32d22bf877 | ||
|
|
ba185a1ac9 | ||
|
|
318570295f | ||
|
|
2ddd2ce5d8 | ||
|
|
f0885921ae | ||
|
|
1ee4562647 | ||
|
|
85c20247f5 | ||
|
|
26f1b19dde | ||
|
|
afa8bce2c9 | ||
|
|
5ad901ca4f | ||
|
|
137be610f8 | ||
|
|
519bdabe61 | ||
|
|
fe0b20f247 | ||
|
|
8112fe8fd6 | ||
|
|
a13dff96c8 | ||
|
|
7d43f02e59 | ||
|
|
06d646c8de | ||
|
|
7fed47e54f | ||
|
|
21f762edd9 | ||
|
|
93bc6713b4 | ||
|
|
e3f7e2f470 | ||
|
|
c949a08981 | ||
|
|
5ab96769ac | ||
|
|
29a0510192 | ||
|
|
0901673c28 | ||
|
|
3dbe4b4ac8 | ||
|
|
40f87c9e04 | ||
|
|
b9aace912b | ||
|
|
8cdd30b9e4 | ||
|
|
c8a6fd294e | ||
|
|
ea9f374568 | ||
|
|
a300d85f6f | ||
|
|
3c8868c899 | ||
|
|
a8bec56d96 | ||
|
|
71d3d9ba8b | ||
|
|
f9b87cc41c | ||
|
|
59bfc9fa5a | ||
|
|
eab5e94452 | ||
|
|
fc43d2bc29 | ||
|
|
8264082719 | ||
|
|
28088b9566 | ||
|
|
644f9ee3ce | ||
|
|
f70ba8bc31 | ||
|
|
6082e75734 | ||
|
|
919fb5f499 | ||
|
|
d1fd1c9ea8 | ||
|
|
30eeb15ece | ||
|
|
9f79309ada | ||
|
|
520846511b | ||
|
|
552e8c0607 |
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch",
|
||||
"mcp__searxng__searxng_web_search",
|
||||
"Bash(git init:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
39
.gitignore
vendored
@@ -1 +1,40 @@
|
||||
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
@@ -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
@@ -0,0 +1,264 @@
|
||||
<div align="center">
|
||||
|
||||
# ⏳ 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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
*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
|
||||
|
||||
### ⏲ 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>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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`
|
||||
@@ -7,7 +7,7 @@
|
||||
<title>ZeroClock</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=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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
15
mini-timer.html
Normal 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
29
package.json
@@ -10,25 +10,36 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.5.0",
|
||||
"pinia": "^2.3.0",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.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/motion": "^3.0.3",
|
||||
"chart.js": "^4.4.0",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"jspdf": "^2.5.0",
|
||||
"lucide-vue-next": "^0.400.0",
|
||||
"marked": "^17.0.3",
|
||||
"pinia": "^2.3.0",
|
||||
"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": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tauri-apps/cli": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@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",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.2.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"autoprefixer": "^10.4.0"
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
5884
src-tauri/Cargo.lock
generated
Normal file
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "local-time-tracker"
|
||||
name = "zeroclock"
|
||||
version = "1.0.0"
|
||||
description = "A local time tracking app with invoicing"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "local_time_tracker_lib"
|
||||
name = "zeroclock_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[build-dependencies]
|
||||
@@ -18,13 +18,28 @@ tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
directories = "5"
|
||||
tauri-plugin-window-state = "2"
|
||||
log = "0.4"
|
||||
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]
|
||||
panic = "abort"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for the app",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "mini-timer"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
@@ -13,6 +13,8 @@
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"shell:allow-open",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
@@ -23,6 +25,10 @@
|
||||
"fs:allow-write-text-file",
|
||||
"notification:allow-is-permission-granted",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 306 B After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 761 B After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 99 B After Width: | Height: | Size: 2.7 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 239 B |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 328 B |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 347 B |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 761 B |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 103 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 852 B |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 121 B |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 171 B |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 199 B |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 130 B |
@@ -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>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 172 B |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 374 B |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 277 B |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 171 B |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 240 B |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 273 B |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 281 B |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 515 B |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 485 B |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 425 B |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 901 B |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 714 B |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 563 B |
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 901 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 761 B After Width: | Height: | Size: 209 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 91 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 116 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 116 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 146 B |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 104 B |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 143 B |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 143 B |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 193 B |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 116 B |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 185 B |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 185 B |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 263 B |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 263 B |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 419 B |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 179 B |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 352 B |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 388 B |
BIN
src-tauri/icons/with-glow/128x128.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/with-glow/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
src-tauri/icons/with-glow/32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/with-glow/64x64.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src-tauri/icons/with-glow/icon.ico
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
src-tauri/icons/with-glow/icon.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
@@ -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(
|
||||
"CREATE TABLE IF NOT EXISTS projects (
|
||||
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(
|
||||
"CREATE TABLE IF NOT EXISTS tasks (
|
||||
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(
|
||||
"CREATE TABLE IF NOT EXISTS time_entries (
|
||||
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(
|
||||
"CREATE TABLE IF NOT EXISTS invoices (
|
||||
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(
|
||||
"CREATE TABLE IF NOT EXISTS invoice_items (
|
||||
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(
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
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
|
||||
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(
|
||||
@@ -113,6 +414,26 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
"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(())
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{Manager, State};
|
||||
use std::path::PathBuf;
|
||||
use tauri::Manager;
|
||||
|
||||
mod database;
|
||||
mod commands;
|
||||
mod os_detection;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Mutex<Connection>,
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
fn get_data_dir() -> PathBuf {
|
||||
let exe_path = std::env::current_exe().unwrap();
|
||||
let exe_dir = exe_path.parent().unwrap();
|
||||
let data_dir = exe_dir.join("data");
|
||||
let data_dir = exe_path.parent().unwrap().join("data");
|
||||
std::fs::create_dir_all(&data_dir).ok();
|
||||
data_dir
|
||||
}
|
||||
@@ -28,25 +28,33 @@ pub fn run() {
|
||||
|
||||
let conn = Connection::open(&db_path).expect("Failed to open database");
|
||||
database::init_db(&conn).expect("Failed to initialize database");
|
||||
commands::seed_default_templates(&data_dir);
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_window_state::Builder::new()
|
||||
.with_denylist(&["mini-timer"])
|
||||
.build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::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![
|
||||
commands::get_clients,
|
||||
commands::create_client,
|
||||
commands::update_client,
|
||||
commands::delete_client,
|
||||
commands::get_client_dependents,
|
||||
commands::get_projects,
|
||||
commands::create_project,
|
||||
commands::update_project,
|
||||
commands::delete_project,
|
||||
commands::get_project_dependents,
|
||||
commands::get_tasks,
|
||||
commands::create_task,
|
||||
commands::delete_task,
|
||||
commands::update_task,
|
||||
commands::get_time_entries,
|
||||
commands::create_time_entry,
|
||||
commands::update_time_entry,
|
||||
@@ -54,8 +62,98 @@ pub fn run() {
|
||||
commands::get_reports,
|
||||
commands::create_invoice,
|
||||
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::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| {
|
||||
#[cfg(desktop)]
|
||||
@@ -68,8 +166,9 @@ pub fn run() {
|
||||
let menu = Menu::with_items(app, &[&show, &quit])?;
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.menu_on_left_click(false)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| {
|
||||
match event.id.as_ref() {
|
||||
"quit" => {
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
)]
|
||||
|
||||
fn main() {
|
||||
local_time_tracker_lib::run();
|
||||
zeroclock_lib::run();
|
||||
}
|
||||
|
||||
348
src-tauri/src/os_detection.rs
Normal 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
|
||||
}
|
||||
@@ -1,33 +1,40 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "LocalTimeTracker",
|
||||
"productName": "ZeroClock",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.localtimetracker.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist",
|
||||
"devtools": true
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "LocalTimeTracker",
|
||||
"title": "ZeroClock",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"resizable": true,
|
||||
"center": true
|
||||
"resizable": 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": {
|
||||
"csp": null
|
||||
}
|
||||
|
||||
470
src/App.vue
@@ -1,38 +1,450 @@
|
||||
<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()
|
||||
}
|
||||
})
|
||||
} 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>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full flex flex-col bg-background">
|
||||
<!-- TitleBar placeholder -->
|
||||
<header class="h-10 flex items-center justify-between px-4 bg-surface border-b border-border" data-tauri-drag-region>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-amber font-semibold">ZeroClock</span>
|
||||
</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 -->
|
||||
<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">
|
||||
Skip to main content
|
||||
</a>
|
||||
<div class="h-full w-full flex flex-col bg-bg-base">
|
||||
<TitleBar />
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Sidebar placeholder -->
|
||||
<aside class="w-16 flex flex-col items-center py-4 bg-surface border-r border-border">
|
||||
<div class="text-text-secondary text-sm">Sidebar</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 p-6 overflow-auto">
|
||||
<router-view />
|
||||
<NavRail />
|
||||
<main id="main-content" class="flex-1 overflow-auto" tabindex="-1">
|
||||
<router-view v-slot="{ Component }">
|
||||
<Transition name="page" mode="out-in" :duration="{ enter: 250, leave: 150 }">
|
||||
<component :is="Component" :key="$route.path" />
|
||||
</Transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
115
src/components/AppCascadeDeleteDialog.vue
Normal 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>
|
||||
482
src/components/AppColorPicker.vue
Normal 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>
|
||||
626
src/components/AppDatePicker.vue
Normal 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>
|
||||
169
src/components/AppDateRangePresets.vue
Normal 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>
|
||||
54
src/components/AppDiscardDialog.vue
Normal 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>
|
||||
159
src/components/AppNumberInput.vue
Normal 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>
|
||||
322
src/components/AppSelect.vue
Normal 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>
|
||||
161
src/components/AppShortcutRecorder.vue
Normal 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>
|
||||
219
src/components/AppTagInput.vue
Normal 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>
|
||||
376
src/components/AppTimePicker.vue
Normal 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>
|
||||
58
src/components/AppTrackingPromptDialog.vue
Normal 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 & Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
123
src/components/EntrySplitDialog.vue
Normal 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>
|
||||
240
src/components/EntryTemplatePicker.vue
Normal 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>
|
||||
164
src/components/GettingStartedChecklist.vue
Normal 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>
|
||||
192
src/components/GlobalSearchDialog.vue
Normal 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>
|
||||
75
src/components/IdlePromptDialog.vue
Normal 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 & Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
212
src/components/InvoicePipelineView.vue
Normal 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>
|
||||
1575
src/components/InvoicePreview.vue
Normal file
139
src/components/InvoiceTemplatePicker.vue
Normal 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>
|
||||
201
src/components/JsonImportWizard.vue
Normal 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>
|
||||