diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 641e5b9..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebSearch", - "mcp__searxng__searxng_web_search", - "Bash(git init:*)", - "Bash(git add:*)", - "Bash(git commit:*)" - ] - } -} diff --git a/.gitignore b/.gitignore index b9833c6..78165d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules dist +docs trash # Rust/Tauri build artifacts diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2695b5 --- /dev/null +++ b/README.md @@ -0,0 +1,264 @@ +
+ +# ⏳ ZeroClock + +**Your time. Your data. Your rules.** + +A local-first time tracker for freelancers, artists, and anyone who believes the value of their labor belongs to them - not a cloud platform. + +![Tauri](https://img.shields.io/badge/Tauri_v2-24C8D8?style=for-the-badge&logo=tauri&logoColor=white) +![Vue](https://img.shields.io/badge/Vue_3-4FC08D?style=for-the-badge&logo=vuedotjs&logoColor=white) +![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white) +![Rust](https://img.shields.io/badge/Rust-000000?style=for-the-badge&logo=rust&logoColor=white) +![SQLite](https://img.shields.io/badge/SQLite-003B57?style=for-the-badge&logo=sqlite&logoColor=white) + +![WCAG AAA](https://img.shields.io/badge/WCAG_2.2-AAA-228B22?style=for-the-badge) +![No Cloud](https://img.shields.io/badge/100%25_Local-No_Cloud-8B0000?style=for-the-badge) +![No Telemetry](https://img.shields.io/badge/Telemetry-None-4B0082?style=for-the-badge) +![License](https://img.shields.io/badge/License-GPL_3.0-blue?style=for-the-badge) + +*No subscriptions. No surveillance. No corporate middleman between you and your work.* + +
+ +--- + +## 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 free software released under the [GNU General Public License v3.0](LICENSE). You can use it, modify it, and share it. The only condition: if you distribute a modified version, you share your changes under the same terms. The commons stay common. + +--- + +
+ +*Built for the people who do the work.* + +*No venture capital. No growth metrics. No exit strategy.* + +*Just a tool that respects your time.* + +
diff --git a/mini-timer.html b/mini-timer.html new file mode 100644 index 0000000..7ca0f2d --- /dev/null +++ b/mini-timer.html @@ -0,0 +1,15 @@ + + + + + + ZeroClock - Mini Timer + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json index 23126b3..91a73aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@vueuse/core": "^12.0.0", "@vueuse/motion": "^3.0.3", "chart.js": "^4.4.0", + "dompurify": "^3.3.1", "jspdf": "^2.5.0", "lucide-vue-next": "^0.400.0", "marked": "^17.0.3", @@ -28,8 +29,12 @@ "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", @@ -523,6 +528,17 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1172,6 +1188,496 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@internationalized/date": { "version": "3.11.0", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.11.0.tgz", @@ -1418,6 +1924,28 @@ "destr": "^2.0.5" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", + "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -2338,6 +2866,13 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@ts-morph/common": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz", @@ -2349,6 +2884,16 @@ "tinyglobby": "^0.2.14" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -2368,6 +2913,16 @@ "license": "MIT", "peer": true }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -2375,12 +2930,30 @@ "license": "MIT", "optional": true }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@unovue/detypes": { "version": "0.8.5", "resolved": "https://registry.npmjs.org/@unovue/detypes/-/detypes-0.8.5.tgz", @@ -2762,6 +3335,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -2924,6 +3507,21 @@ "postcss": "^8.1.0" } }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/balanced-match": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", @@ -2936,6 +3534,104 @@ "node": "20 || >=22" } }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", + "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", + "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-arraybuffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", @@ -2955,6 +3651,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/basic-ftp": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", + "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -3054,6 +3760,16 @@ "node": ">= 0.4.0" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3199,6 +3915,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", @@ -3282,6 +4012,59 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", @@ -3513,6 +4296,16 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -3589,6 +4382,34 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "license": "MIT" }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/degenerator/node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3614,6 +4435,13 @@ "node": ">=8" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1566079", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", + "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/diff": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", @@ -3677,11 +4505,13 @@ } }, "node_modules/dompurify": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", - "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } }, "node_modules/domutils": { "version": "3.2.2", @@ -3767,6 +4597,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", @@ -3900,6 +4740,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.0.tgz", @@ -4040,6 +4902,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -4084,7 +4960,6 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4098,6 +4973,16 @@ "node": ">= 0.6" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -4209,6 +5094,43 @@ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4221,6 +5143,13 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "license": "Apache-2.0" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4276,6 +5205,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4529,6 +5468,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -4614,6 +5563,21 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -4782,6 +5746,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -5125,6 +6117,13 @@ "html2canvas": "^1.0.0-rc.5" } }, + "node_modules/jspdf/node_modules/dompurify": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", + "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5691,6 +6690,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -5770,6 +6776,16 @@ "node": ">= 0.6" } }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -6021,6 +7037,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -6111,6 +7161,13 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -6184,6 +7241,34 @@ "pathe": "^2.0.3" } }, + "node_modules/png-to-ico": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/png-to-ico/-/png-to-ico-3.0.1.tgz", + "integrity": "sha512-S8BOAoaGd9gT5uaemQ62arIY3Jzco7Uc7LwUTqRyqJDTsKqOAiyfyN4dSdT0D+Zf8XvgztgpRbM5wnQd7EgYwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^22.10.3", + "minimist": "^1.2.8", + "pngjs": "^7.0.0" + }, + "bin": { + "png-to-ico": "bin/cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/popmotion": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-11.0.5.tgz", @@ -6332,6 +7417,16 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -6358,6 +7453,54 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6368,6 +7511,25 @@ "node": ">=6" } }, + "node_modules/puppeteer-core": { + "version": "24.37.5", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.5.tgz", + "integrity": "sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.0", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1566079", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -6541,6 +7703,16 @@ "vue": "^3.5.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6842,6 +8014,51 @@ "shadcn-vue": "dist/index.js" } }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6964,6 +8181,47 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7024,6 +8282,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string-width": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz", @@ -7274,6 +8544,54 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -7406,6 +8724,13 @@ "node": ">= 0.6" } }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -7457,6 +8782,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -7819,6 +9151,13 @@ "integrity": "sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==", "license": "Apache-2.0" }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -7851,18 +9190,184 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 9625796..fc82e44 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@vueuse/core": "^12.0.0", "@vueuse/motion": "^3.0.3", "chart.js": "^4.4.0", + "dompurify": "^3.3.1", "jspdf": "^2.5.0", "lucide-vue-next": "^0.400.0", "marked": "^17.0.3", @@ -30,8 +31,12 @@ "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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 160264f..c9844fe 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2100,28 +2100,6 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" -[[package]] -name = "local-time-tracker" -version = "1.0.0" -dependencies = [ - "chrono", - "env_logger", - "log", - "png", - "rusqlite", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-fs", - "tauri-plugin-global-shortcut", - "tauri-plugin-notification", - "tauri-plugin-shell", - "tauri-plugin-window-state", - "windows 0.58.0", -] - [[package]] name = "lock_api" version = "0.4.14" @@ -5763,6 +5741,28 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zeroclock" +version = "1.0.0" +dependencies = [ + "chrono", + "env_logger", + "log", + "png", + "rusqlite", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-global-shortcut", + "tauri-plugin-notification", + "tauri-plugin-shell", + "tauri-plugin-window-state", + "windows 0.58.0", +] + [[package]] name = "zerocopy" version = "0.8.39" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3057386..a9d5f9d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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] diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 7a9bb19..cf2c217 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index c016d71..60694eb 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 5d1833c..ec94937 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..735fd47 Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..932600c Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..3ec3cb4 Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..09b63b8 Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..d74e3ea Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..272a474 Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..efb58a7 Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..240c974 Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..5beaee8 Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..608d3b3 Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..9b266a8 Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..4c1d47d Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5dd6188 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..164d86c Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c4f744f Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7ff87f0 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..b867e6e Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..a12480b Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..40f23e3 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..14e2763 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..4c929cf Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..0dc364b Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e3a2beb Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4c89815 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e873c9e Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..73ef0f0 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 9409991..ebabe47 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index f6290c1..b0c203e 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index c016d71..ebe3fa3 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..3b42b5d Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..04dead7 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..04dead7 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..0358e0e Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..3db00fd Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..ac5abab Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..ac5abab Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..dd24142 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..04dead7 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..7daf48c Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..7daf48c Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..42aac9e Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..43c141d Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..42aac9e Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..4d5a818 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..39dd287 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..34f1f51 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..20db191 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/icons/with-glow/128x128.png b/src-tauri/icons/with-glow/128x128.png new file mode 100644 index 0000000..768255d Binary files /dev/null and b/src-tauri/icons/with-glow/128x128.png differ diff --git a/src-tauri/icons/with-glow/128x128@2x.png b/src-tauri/icons/with-glow/128x128@2x.png new file mode 100644 index 0000000..1066ff2 Binary files /dev/null and b/src-tauri/icons/with-glow/128x128@2x.png differ diff --git a/src-tauri/icons/with-glow/32x32.png b/src-tauri/icons/with-glow/32x32.png new file mode 100644 index 0000000..4031bc1 Binary files /dev/null and b/src-tauri/icons/with-glow/32x32.png differ diff --git a/src-tauri/icons/with-glow/64x64.png b/src-tauri/icons/with-glow/64x64.png new file mode 100644 index 0000000..884527d Binary files /dev/null and b/src-tauri/icons/with-glow/64x64.png differ diff --git a/src-tauri/icons/with-glow/icon.ico b/src-tauri/icons/with-glow/icon.ico new file mode 100644 index 0000000..ff6a882 Binary files /dev/null and b/src-tauri/icons/with-glow/icon.ico differ diff --git a/src-tauri/icons/with-glow/icon.png b/src-tauri/icons/with-glow/icon.png new file mode 100644 index 0000000..fbd3e33 Binary files /dev/null and b/src-tauri/icons/with-glow/icon.png differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5c503a0..62dc214 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -15,6 +15,7 @@ pub struct Client { pub tax_id: Option, pub payment_terms: Option, pub notes: Option, + pub currency: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -28,6 +29,8 @@ pub struct Project { pub budget_hours: Option, pub budget_amount: Option, pub rounding_override: Option, + pub notes: Option, + pub currency: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -36,6 +39,7 @@ pub struct Task { pub project_id: i64, pub name: String, pub estimated_hours: Option, + pub hourly_rate: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -72,7 +76,7 @@ pub struct Invoice { pub fn get_clients(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( - "SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes FROM clients ORDER BY name" + "SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes, currency FROM clients ORDER BY name" ).map_err(|e| e.to_string())?; let clients = stmt.query_map([], |row| { Ok(Client { @@ -85,6 +89,7 @@ pub fn get_clients(state: State) -> Result, String> { tax_id: row.get(6)?, payment_terms: row.get(7)?, notes: row.get(8)?, + currency: row.get(9)?, }) }).map_err(|e| e.to_string())?; clients.collect::, _>>().map_err(|e| e.to_string()) @@ -94,8 +99,8 @@ pub fn get_clients(state: State) -> Result, String> { pub fn create_client(state: State, client: Client) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( - "INSERT INTO clients (name, email, address, company, phone, tax_id, payment_terms, notes) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes], + "INSERT INTO clients (name, email, address, company, phone, tax_id, payment_terms, notes, currency) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes, client.currency], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } @@ -104,8 +109,8 @@ pub fn create_client(state: State, client: Client) -> Result, client: Client) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( - "UPDATE clients SET name = ?1, email = ?2, address = ?3, company = ?4, phone = ?5, tax_id = ?6, payment_terms = ?7, notes = ?8 WHERE id = ?9", - params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes, client.id], + "UPDATE clients SET name = ?1, email = ?2, address = ?3, company = ?4, phone = ?5, tax_id = ?6, payment_terms = ?7, notes = ?8, currency = ?9 WHERE id = ?10", + params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes, client.currency, client.id], ).map_err(|e| e.to_string())?; Ok(()) } @@ -143,18 +148,23 @@ pub fn delete_client(state: State, id: i64) -> Result<(), String> { }; for pid in &project_ids { + conn.execute("DELETE FROM timeline_events WHERE project_id = ?1", params![pid])?; + conn.execute("DELETE FROM invoice_items WHERE time_entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![pid])?; conn.execute("DELETE FROM entry_tags WHERE entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![pid])?; conn.execute("DELETE FROM time_entries WHERE project_id = ?1", params![pid])?; - conn.execute("DELETE FROM tasks WHERE project_id = ?1", params![pid])?; conn.execute("DELETE FROM tracked_apps WHERE project_id = ?1", params![pid])?; conn.execute("DELETE FROM favorites WHERE project_id = ?1", params![pid])?; conn.execute("DELETE FROM recurring_entries WHERE project_id = ?1", params![pid])?; - conn.execute("DELETE FROM timeline_events WHERE project_id = ?1", params![pid])?; + conn.execute("DELETE FROM entry_templates WHERE project_id = ?1", params![pid])?; + conn.execute("DELETE FROM timesheet_rows WHERE project_id = ?1", params![pid])?; + conn.execute("DELETE FROM tasks WHERE project_id = ?1", params![pid])?; } conn.execute("DELETE FROM expenses WHERE client_id = ?1", params![id])?; + conn.execute("DELETE FROM invoice_payments WHERE invoice_id IN (SELECT id FROM invoices WHERE client_id = ?1)", params![id])?; conn.execute("DELETE FROM invoice_items WHERE invoice_id IN (SELECT id FROM invoices WHERE client_id = ?1)", params![id])?; conn.execute("DELETE FROM invoices WHERE client_id = ?1", params![id])?; + conn.execute("DELETE FROM recurring_invoices WHERE client_id = ?1", params![id])?; conn.execute("DELETE FROM projects WHERE client_id = ?1", params![id])?; conn.execute("DELETE FROM clients WHERE id = ?1", params![id])?; Ok(()) @@ -177,7 +187,7 @@ pub fn delete_client(state: State, id: i64) -> Result<(), String> { pub fn get_projects(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( - "SELECT id, client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override FROM projects ORDER BY name" + "SELECT id, client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency FROM projects ORDER BY name" ).map_err(|e| e.to_string())?; let projects = stmt.query_map([], |row| { Ok(Project { @@ -190,6 +200,8 @@ pub fn get_projects(state: State) -> Result, String> { budget_hours: row.get(6)?, budget_amount: row.get(7)?, rounding_override: row.get(8)?, + notes: row.get(9)?, + currency: row.get(10)?, }) }).map_err(|e| e.to_string())?; projects.collect::, _>>().map_err(|e| e.to_string()) @@ -199,8 +211,8 @@ pub fn get_projects(state: State) -> Result, String> { pub fn create_project(state: State, project: Project) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( - "INSERT INTO projects (client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32, project.budget_hours, project.budget_amount, project.rounding_override], + "INSERT INTO projects (client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32, project.budget_hours, project.budget_amount, project.rounding_override, project.notes, project.currency], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } @@ -209,8 +221,8 @@ pub fn create_project(state: State, project: Project) -> Result, project: Project) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( - "UPDATE projects SET client_id = ?1, name = ?2, hourly_rate = ?3, color = ?4, archived = ?5, budget_hours = ?6, budget_amount = ?7, rounding_override = ?8 WHERE id = ?9", - params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32, project.budget_hours, project.budget_amount, project.rounding_override, project.id], + "UPDATE projects SET client_id = ?1, name = ?2, hourly_rate = ?3, color = ?4, archived = ?5, budget_hours = ?6, budget_amount = ?7, rounding_override = ?8, notes = ?9, currency = ?10 WHERE id = ?11", + params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32, project.budget_hours, project.budget_amount, project.rounding_override, project.notes, project.currency, project.id], ).map_err(|e| e.to_string())?; Ok(()) } @@ -255,6 +267,7 @@ pub fn delete_project(state: State, id: i64) -> Result<(), String> { let result = (|| -> Result<(), rusqlite::Error> { conn.execute("DELETE FROM timeline_events WHERE project_id = ?1", params![id])?; + conn.execute("DELETE FROM invoice_items WHERE time_entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![id])?; conn.execute( "DELETE FROM entry_tags WHERE entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![id], @@ -264,6 +277,8 @@ pub fn delete_project(state: State, id: i64) -> Result<(), String> { conn.execute("DELETE FROM expenses WHERE project_id = ?1", params![id])?; conn.execute("DELETE FROM recurring_entries WHERE project_id = ?1", params![id])?; conn.execute("DELETE FROM tracked_apps WHERE project_id = ?1", params![id])?; + conn.execute("DELETE FROM entry_templates WHERE project_id = ?1", params![id])?; + conn.execute("DELETE FROM timesheet_rows WHERE project_id = ?1", params![id])?; conn.execute("DELETE FROM tasks WHERE project_id = ?1", params![id])?; conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?; Ok(()) @@ -285,13 +300,14 @@ pub fn delete_project(state: State, id: i64) -> Result<(), String> { #[tauri::command] pub fn get_tasks(state: State, project_id: i64) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; - let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours FROM tasks WHERE project_id = ?1 ORDER BY name").map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours, hourly_rate FROM tasks WHERE project_id = ?1 ORDER BY name").map_err(|e| e.to_string())?; let tasks = stmt.query_map(params![project_id], |row| { Ok(Task { id: Some(row.get(0)?), project_id: row.get(1)?, name: row.get(2)?, estimated_hours: row.get(3)?, + hourly_rate: row.get(4)?, }) }).map_err(|e| e.to_string())?; tasks.collect::, _>>().map_err(|e| e.to_string()) @@ -301,8 +317,8 @@ pub fn get_tasks(state: State, project_id: i64) -> Result, S pub fn create_task(state: State, task: Task) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( - "INSERT INTO tasks (project_id, name, estimated_hours) VALUES (?1, ?2, ?3)", - params![task.project_id, task.name, task.estimated_hours], + "INSERT INTO tasks (project_id, name, estimated_hours, hourly_rate) VALUES (?1, ?2, ?3, ?4)", + params![task.project_id, task.name, task.estimated_hours, task.hourly_rate], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } @@ -310,6 +326,9 @@ pub fn create_task(state: State, task: Task) -> Result { #[tauri::command] pub fn delete_task(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM entry_templates WHERE task_id = ?1", params![id]).map_err(|e| e.to_string())?; + conn.execute("DELETE FROM timesheet_rows WHERE task_id = ?1", params![id]).map_err(|e| e.to_string())?; + conn.execute("DELETE FROM recurring_entries WHERE task_id = ?1", params![id]).map_err(|e| e.to_string())?; conn.execute("DELETE FROM tasks WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; Ok(()) } @@ -318,8 +337,8 @@ pub fn delete_task(state: State, id: i64) -> Result<(), String> { pub fn update_task(state: State, task: Task) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( - "UPDATE tasks SET name = ?1, estimated_hours = ?2 WHERE id = ?3", - params![task.name, task.estimated_hours, task.id], + "UPDATE tasks SET name = ?1, estimated_hours = ?2, hourly_rate = ?3 WHERE id = ?4", + params![task.name, task.estimated_hours, task.hourly_rate, task.id], ).map_err(|e| e.to_string())?; Ok(()) } @@ -442,6 +461,8 @@ pub fn delete_time_entry(state: State, id: i64) -> Result<(), String> if locked { return Err("Cannot modify entries in a locked week".to_string()); } + conn.execute("DELETE FROM invoice_items WHERE time_entry_id = ?1", params![id]).map_err(|e| e.to_string())?; + conn.execute("DELETE FROM entry_tags WHERE entry_id = ?1", params![id]).map_err(|e| e.to_string())?; conn.execute("DELETE FROM time_entries WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; Ok(()) } @@ -543,6 +564,7 @@ pub fn update_invoice(state: State, invoice: Invoice) -> Result<(), St #[tauri::command] pub fn delete_invoice(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM invoice_payments WHERE invoice_id = ?1", params![id]).map_err(|e| e.to_string())?; conn.execute("DELETE FROM invoice_items WHERE invoice_id = ?1", params![id]).map_err(|e| e.to_string())?; conn.execute("DELETE FROM invoices WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; Ok(()) @@ -681,7 +703,7 @@ pub fn export_data(state: State) -> Result let conn = state.db.lock().map_err(|e| e.to_string())?; let clients = { - let mut stmt = conn.prepare("SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes FROM clients").map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes, currency FROM clients").map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map([], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, @@ -692,14 +714,15 @@ pub fn export_data(state: State) -> Result "phone": row.get::<_, Option>(5)?, "tax_id": row.get::<_, Option>(6)?, "payment_terms": row.get::<_, Option>(7)?, - "notes": row.get::<_, Option>(8)? + "notes": row.get::<_, Option>(8)?, + "currency": row.get::<_, Option>(9)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let projects = { - let mut stmt = conn.prepare("SELECT id, client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override FROM projects").map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("SELECT id, client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency FROM projects").map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map([], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, @@ -710,20 +733,23 @@ pub fn export_data(state: State) -> Result "archived": row.get::<_, i32>(5)? != 0, "budget_hours": row.get::<_, Option>(6)?, "budget_amount": row.get::<_, Option>(7)?, - "rounding_override": row.get::<_, Option>(8)? + "rounding_override": row.get::<_, Option>(8)?, + "notes": row.get::<_, Option>(9)?, + "currency": row.get::<_, Option>(10)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let tasks = { - let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours FROM tasks").map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours, hourly_rate FROM tasks").map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map([], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, "project_id": row.get::<_, i64>(1)?, "name": row.get::<_, String>(2)?, - "estimated_hours": row.get::<_, Option>(3)? + "estimated_hours": row.get::<_, Option>(3)?, + "hourly_rate": row.get::<_, Option>(4)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows @@ -1004,13 +1030,26 @@ pub fn export_data(state: State) -> Result pub fn clear_all_data(state: State) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute_batch( - "DELETE FROM tracked_apps; + "DELETE FROM entry_tags; + DELETE FROM invoice_payments; DELETE FROM invoice_items; + DELETE FROM recurring_invoices; DELETE FROM invoices; + DELETE FROM favorites; + DELETE FROM recurring_entries; + DELETE FROM entry_templates; + DELETE FROM timesheet_rows; + DELETE FROM timesheet_locks; + DELETE FROM timeline_events; + DELETE FROM expenses; + DELETE FROM tracked_apps; DELETE FROM time_entries; DELETE FROM tasks; DELETE FROM projects; - DELETE FROM clients;" + DELETE FROM clients; + DELETE FROM tags; + DELETE FROM calendar_events; + DELETE FROM calendar_sources;" ).map_err(|e| e.to_string())?; Ok(()) } @@ -1669,7 +1708,7 @@ pub fn get_goal_progress(state: State, today: String) -> Result, start_date: String, end_date: String) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; @@ -1687,7 +1726,8 @@ pub fn get_profitability_report(state: State, start_date: String, end_ ORDER BY total_seconds DESC" ).map_err(|e| e.to_string())?; - let rows = stmt.query_map(params![start_date, end_date], |row| { + let rows: Vec = stmt.query_map(params![start_date, end_date], |row| { + let project_id: i64 = row.get(0)?; let total_seconds: i64 = row.get(7)?; let hourly_rate: f64 = row.get(3)?; let hours = total_seconds as f64 / 3600.0; @@ -1696,22 +1736,38 @@ pub fn get_profitability_report(state: State, start_date: String, end_ let budget_amount: Option = row.get(5)?; Ok(serde_json::json!({ - "project_id": row.get::<_, i64>(0)?, + "project_id": project_id, "project_name": row.get::<_, String>(1)?, "color": row.get::<_, String>(2)?, "hourly_rate": hourly_rate, "client_name": row.get::<_, Option>(6)?, "total_seconds": total_seconds, - "hours": hours, + "total_hours": hours, "revenue": revenue, "budget_hours": budget_hours, "budget_amount": budget_amount, - "percent_hours": budget_hours.map(|b| if b > 0.0 { (hours / b) * 100.0 } else { 0.0 }), + "budget_used_pct": budget_hours.map(|b| if b > 0.0 { (hours / b) * 100.0 } else { 0.0 }), "percent_amount": budget_amount.map(|b| if b > 0.0 { (revenue / b) * 100.0 } else { 0.0 }) })) - }).map_err(|e| e.to_string())?; + }).map_err(|e| e.to_string())? + .collect::, _>>().map_err(|e| e.to_string())?; - rows.collect::, _>>().map_err(|e| e.to_string()) + // Add expense totals per project for the date range + let mut result: Vec = Vec::new(); + for mut row in rows { + let pid = row["project_id"].as_i64().unwrap_or(0); + let expense_total: f64 = conn.query_row( + "SELECT COALESCE(SUM(amount), 0) FROM expenses WHERE project_id = ?1 AND date >= ?2 AND date <= ?3", + params![pid, start_date, end_date], + |r| r.get(0), + ).unwrap_or(0.0); + let revenue = row["revenue"].as_f64().unwrap_or(0.0); + row.as_object_mut().unwrap().insert("expenses".into(), serde_json::json!(expense_total)); + row.as_object_mut().unwrap().insert("net_profit".into(), serde_json::json!(revenue - expense_total)); + result.push(row); + } + + Ok(result) } // Timesheet data command @@ -2239,6 +2295,392 @@ pub fn auto_backup(state: State, backup_dir: String) -> Result Result, String> { + let dir = std::path::Path::new(&backup_dir); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut files: Vec = std::fs::read_dir(dir) + .map_err(|e| e.to_string())? + .flatten() + .filter(|e| { + e.path().extension().and_then(|ext| ext.to_str()) == Some("json") + && e.file_name().to_string_lossy().starts_with("zeroclock-backup-") + }) + .filter_map(|e| { + let meta = e.metadata().ok()?; + let modified = meta.modified().ok()?; + Some(serde_json::json!({ + "path": e.path().to_string_lossy().to_string(), + "name": e.file_name().to_string_lossy().to_string(), + "size": meta.len(), + "modified": modified.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(), + })) + }) + .collect(); + files.sort_by(|a, b| { + b.get("modified").and_then(|v| v.as_u64()) + .cmp(&a.get("modified").and_then(|v| v.as_u64())) + }); + Ok(files) +} + +#[tauri::command] +pub fn delete_backup_file(path: String) -> Result<(), String> { + std::fs::remove_file(&path).map_err(|e| e.to_string()) +} + +// Get recent unique descriptions for autocomplete +#[tauri::command] +pub fn get_recent_descriptions(state: State) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let mut stmt = conn.prepare( + "SELECT description, COUNT(*) as cnt FROM time_entries + WHERE description IS NOT NULL AND description != '' + GROUP BY description ORDER BY cnt DESC LIMIT 50" + ).map_err(|e| e.to_string())?; + let rows = stmt.query_map([], |row| { + row.get::<_, String>(0) + }).map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) +} + +// Check for overlapping time entries +#[tauri::command] +pub fn check_entry_overlap( + state: State, + start_time: String, + end_time: String, + exclude_id: Option, +) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let query = if let Some(eid) = exclude_id { + let mut stmt = conn.prepare( + "SELECT te.id, te.description, te.start_time, te.end_time, p.name as project_name + FROM time_entries te + JOIN projects p ON te.project_id = p.id + WHERE te.end_time IS NOT NULL + AND te.id != ?3 + AND te.start_time < ?2 + AND te.end_time > ?1 + ORDER BY te.start_time" + ).map_err(|e| e.to_string())?; + let rows = stmt.query_map(params![start_time, end_time, eid], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "description": row.get::<_, Option>(1)?, + "start_time": row.get::<_, String>(2)?, + "end_time": row.get::<_, Option>(3)?, + "project_name": row.get::<_, String>(4)? + })) + }).map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string())? + } else { + let mut stmt = conn.prepare( + "SELECT te.id, te.description, te.start_time, te.end_time, p.name as project_name + FROM time_entries te + JOIN projects p ON te.project_id = p.id + WHERE te.end_time IS NOT NULL + AND te.start_time < ?2 + AND te.end_time > ?1 + ORDER BY te.start_time" + ).map_err(|e| e.to_string())?; + let rows = stmt.query_map(params![start_time, end_time], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "description": row.get::<_, Option>(1)?, + "start_time": row.get::<_, String>(2)?, + "end_time": row.get::<_, Option>(3)?, + "project_name": row.get::<_, String>(4)? + })) + }).map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string())? + }; + Ok(query) +} + +// Get actual hours by task for a project (estimates vs actuals) +#[tauri::command] +pub fn get_task_actuals(state: State, project_id: i64) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let mut stmt = conn.prepare( + "SELECT t.id, t.name, t.estimated_hours, t.hourly_rate, + COALESCE(SUM(te.duration), 0) as actual_seconds + FROM tasks t + LEFT JOIN time_entries te ON te.task_id = t.id + WHERE t.project_id = ?1 + GROUP BY t.id + ORDER BY t.name" + ).map_err(|e| e.to_string())?; + let rows = stmt.query_map(params![project_id], |row| { + let estimated: Option = row.get(2)?; + let actual_seconds: i64 = row.get(4)?; + let actual_hours = actual_seconds as f64 / 3600.0; + let variance = estimated.map(|est| actual_hours - est); + let progress = estimated.map(|est| if est > 0.0 { (actual_hours / est) * 100.0 } else { 0.0 }); + Ok(serde_json::json!({ + "task_id": row.get::<_, i64>(0)?, + "task_name": row.get::<_, String>(1)?, + "estimated_hours": estimated, + "hourly_rate": row.get::<_, Option>(3)?, + "actual_seconds": actual_seconds, + "actual_hours": actual_hours, + "variance": variance, + "progress": progress + })) + }).map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) +} + +// Invoice payment struct and commands +#[derive(Debug, Serialize, Deserialize)] +pub struct InvoicePayment { + pub id: Option, + pub invoice_id: i64, + pub amount: f64, + pub date: String, + pub method: Option, + pub notes: Option, +} + +#[tauri::command] +pub fn get_invoice_payments(state: State, invoice_id: i64) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let mut stmt = conn.prepare( + "SELECT id, invoice_id, amount, date, method, notes FROM invoice_payments WHERE invoice_id = ?1 ORDER BY date" + ).map_err(|e| e.to_string())?; + let payments = stmt.query_map(params![invoice_id], |row| { + Ok(InvoicePayment { + id: Some(row.get(0)?), + invoice_id: row.get(1)?, + amount: row.get(2)?, + date: row.get(3)?, + method: row.get(4)?, + notes: row.get(5)?, + }) + }).map_err(|e| e.to_string())?; + payments.collect::, _>>().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn add_invoice_payment(state: State, payment: InvoicePayment) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute( + "INSERT INTO invoice_payments (invoice_id, amount, date, method, notes) VALUES (?1, ?2, ?3, ?4, ?5)", + params![payment.invoice_id, payment.amount, payment.date, payment.method, payment.notes], + ).map_err(|e| e.to_string())?; + + // Update invoice status based on total paid + let total_paid: f64 = conn.query_row( + "SELECT COALESCE(SUM(amount), 0) FROM invoice_payments WHERE invoice_id = ?1", + params![payment.invoice_id], + |row| row.get(0), + ).map_err(|e| e.to_string())?; + let invoice_total: f64 = conn.query_row( + "SELECT total FROM invoices WHERE id = ?1", + params![payment.invoice_id], + |row| row.get(0), + ).map_err(|e| e.to_string())?; + + let new_status = if total_paid >= invoice_total { "paid" } else { "partial" }; + conn.execute( + "UPDATE invoices SET status = ?1 WHERE id = ?2", + params![new_status, payment.invoice_id], + ).map_err(|e| e.to_string())?; + + Ok(conn.last_insert_rowid()) +} + +#[tauri::command] +pub fn delete_invoice_payment(state: State, id: i64, invoice_id: i64) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM invoice_payments WHERE id = ?1", params![id]) + .map_err(|e| e.to_string())?; + + // Recalculate invoice status + let total_paid: f64 = conn.query_row( + "SELECT COALESCE(SUM(amount), 0) FROM invoice_payments WHERE invoice_id = ?1", + params![invoice_id], + |row| row.get(0), + ).map_err(|e| e.to_string())?; + let invoice_total: f64 = conn.query_row( + "SELECT total FROM invoices WHERE id = ?1", + params![invoice_id], + |row| row.get(0), + ).map_err(|e| e.to_string())?; + + let new_status = if total_paid >= invoice_total { + "paid" + } else if total_paid > 0.0 { + "partial" + } else { + "sent" + }; + conn.execute( + "UPDATE invoices SET status = ?1 WHERE id = ?2", + params![new_status, invoice_id], + ).map_err(|e| e.to_string())?; + + Ok(()) +} + +// Recurring invoice struct and commands +#[derive(Debug, Serialize, Deserialize)] +pub struct RecurringInvoice { + pub id: Option, + pub client_id: i64, + pub template_id: Option, + pub line_items_json: String, + pub tax_rate: f64, + pub discount: f64, + pub notes: Option, + pub recurrence_rule: String, + pub next_due_date: String, + pub enabled: Option, +} + +#[tauri::command] +pub fn get_recurring_invoices(state: State) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let mut stmt = conn.prepare( + "SELECT id, client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled + FROM recurring_invoices ORDER BY next_due_date" + ).map_err(|e| e.to_string())?; + let rows = stmt.query_map([], |row| { + Ok(RecurringInvoice { + id: Some(row.get(0)?), + client_id: row.get(1)?, + template_id: row.get(2)?, + line_items_json: row.get(3)?, + tax_rate: row.get(4)?, + discount: row.get(5)?, + notes: row.get(6)?, + recurrence_rule: row.get(7)?, + next_due_date: row.get(8)?, + enabled: row.get(9)?, + }) + }).map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn create_recurring_invoice(state: State, invoice: RecurringInvoice) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute( + "INSERT INTO recurring_invoices (client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![invoice.client_id, invoice.template_id, invoice.line_items_json, invoice.tax_rate, + invoice.discount, invoice.notes, invoice.recurrence_rule, invoice.next_due_date, invoice.enabled.unwrap_or(1)], + ).map_err(|e| e.to_string())?; + Ok(conn.last_insert_rowid()) +} + +#[tauri::command] +pub fn update_recurring_invoice(state: State, invoice: RecurringInvoice) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute( + "UPDATE recurring_invoices SET client_id = ?1, template_id = ?2, line_items_json = ?3, + tax_rate = ?4, discount = ?5, notes = ?6, recurrence_rule = ?7, next_due_date = ?8, enabled = ?9 + WHERE id = ?10", + params![invoice.client_id, invoice.template_id, invoice.line_items_json, invoice.tax_rate, + invoice.discount, invoice.notes, invoice.recurrence_rule, invoice.next_due_date, invoice.enabled, invoice.id], + ).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn delete_recurring_invoice(state: State, id: i64) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM recurring_invoices WHERE id = ?1", params![id]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +// Check recurring invoices and auto-create drafts when due +#[tauri::command] +pub fn check_recurring_invoices(state: State, today: String) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let mut stmt = conn.prepare( + "SELECT id, client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date + FROM recurring_invoices WHERE enabled = 1 AND date(next_due_date) <= date(?1)" + ).map_err(|e| e.to_string())?; + + let due: Vec<(i64, i64, Option, String, f64, f64, Option, String, String)> = stmt + .query_map(params![today], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, + row.get(5)?, row.get(6)?, row.get(7)?, row.get(8)?)) + }) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + + let mut created_ids: Vec = Vec::new(); + for (ri_id, client_id, template_id, line_items_json, tax_rate, discount, notes, rule, next_due) in &due { + // Generate invoice number + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM invoices", [], |row| row.get(0) + ).map_err(|e| e.to_string())?; + let inv_number = format!("INV-{:04}", count + 1); + + // Parse line items to calculate totals + let items: Vec = serde_json::from_str(line_items_json).unwrap_or_default(); + let subtotal: f64 = items.iter().map(|item| { + let qty = item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(0.0); + let rate = item.get("unit_price").and_then(|v| v.as_f64()).unwrap_or(0.0); + qty * rate + }).sum(); + let tax_amount = subtotal * tax_rate / 100.0; + let total = subtotal + tax_amount - discount; + + conn.execute( + "INSERT INTO invoices (client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'draft', ?11)", + params![client_id, inv_number, next_due, next_due, subtotal, tax_rate, tax_amount, discount, total, notes, template_id], + ).map_err(|e| e.to_string())?; + let invoice_id = conn.last_insert_rowid(); + + // Insert line items + for item in &items { + let desc = item.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let qty = item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(0.0); + let rate = item.get("unit_price").and_then(|v| v.as_f64()).unwrap_or(0.0); + let amount = qty * rate; + conn.execute( + "INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount) VALUES (?1, ?2, ?3, ?4, ?5)", + params![invoice_id, desc, qty, rate, amount], + ).map_err(|e| e.to_string())?; + } + + created_ids.push(invoice_id); + + // Advance next_due_date based on recurrence rule + let next: String = match rule.as_str() { + "weekly" => conn.query_row( + "SELECT date(?1, '+7 days')", params![next_due], |row| row.get(0) + ).map_err(|e| e.to_string())?, + "biweekly" => conn.query_row( + "SELECT date(?1, '+14 days')", params![next_due], |row| row.get(0) + ).map_err(|e| e.to_string())?, + "quarterly" => conn.query_row( + "SELECT date(?1, '+3 months')", params![next_due], |row| row.get(0) + ).map_err(|e| e.to_string())?, + "yearly" => conn.query_row( + "SELECT date(?1, '+1 year')", params![next_due], |row| row.get(0) + ).map_err(|e| e.to_string())?, + _ => conn.query_row( + "SELECT date(?1, '+1 month')", params![next_due], |row| row.get(0) + ).map_err(|e| e.to_string())?, + }; + conn.execute( + "UPDATE recurring_invoices SET next_due_date = ?1 WHERE id = ?2", + params![next, ri_id], + ).map_err(|e| e.to_string())?; + } + + Ok(created_ids) +} + pub fn seed_default_templates(data_dir: &std::path::Path) { let templates_dir = data_dir.join("templates"); std::fs::create_dir_all(&templates_dir).ok(); @@ -2296,6 +2738,8 @@ struct ParsedCalendarEvent { start_time: Option, end_time: Option, location: Option, + description: Option, + duration: i64, } fn parse_ics_datetime(dt: &str) -> Option { @@ -2323,7 +2767,67 @@ fn parse_ics_datetime(dt: &str) -> Option { } } +fn unfold_ics_lines(content: &str) -> String { + let mut result = String::new(); + for line in content.lines() { + let line = line.trim_end_matches('\r'); + if line.starts_with(' ') || line.starts_with('\t') { + result.push_str(line.trim_start()); + } else { + if !result.is_empty() { + result.push('\n'); + } + result.push_str(line); + } + } + result +} + +fn parse_ics_duration(dur: &str) -> Option { + let dur = dur.strip_prefix("PT")?; + let mut seconds: i64 = 0; + let mut num_buf = String::new(); + for ch in dur.chars() { + if ch.is_ascii_digit() { + num_buf.push(ch); + } else { + let n: i64 = num_buf.parse().ok()?; + num_buf.clear(); + match ch { + 'H' => seconds += n * 3600, + 'M' => seconds += n * 60, + 'S' => seconds += n, + _ => {} + } + } + } + Some(seconds) +} + +fn calc_ics_duration_from_times(start: &str, end: &str) -> i64 { + let parse_ts = |s: &str| -> Option { + let s = s.trim(); + if s.len() >= 15 { + let year: i64 = s[0..4].parse().ok()?; + let month: i64 = s[4..6].parse().ok()?; + let day: i64 = s[6..8].parse().ok()?; + let hour: i64 = s[9..11].parse().ok()?; + let min: i64 = s[11..13].parse().ok()?; + let sec: i64 = s[13..15].parse().ok()?; + // Approximate seconds since epoch (good enough for duration calc) + Some(((year * 365 + month * 30 + day) * 86400) + hour * 3600 + min * 60 + sec) + } else { + None + } + }; + match (parse_ts(start), parse_ts(end)) { + (Some(s), Some(e)) if e > s => e - s, + _ => 0, + } +} + fn parse_ics_content(content: &str) -> Vec { + let unfolded = unfold_ics_lines(content); let mut events = Vec::new(); let mut in_event = false; let mut uid = String::new(); @@ -2331,9 +2835,10 @@ fn parse_ics_content(content: &str) -> Vec { let mut dtstart = String::new(); let mut dtend = String::new(); let mut location = String::new(); + let mut description = String::new(); + let mut duration_str = String::new(); - for line in content.lines() { - let line = line.trim_end_matches('\r'); + for line in unfolded.lines() { if line == "BEGIN:VEVENT" { in_event = true; uid.clear(); @@ -2341,22 +2846,25 @@ fn parse_ics_content(content: &str) -> Vec { dtstart.clear(); dtend.clear(); location.clear(); + description.clear(); + duration_str.clear(); } else if line == "END:VEVENT" { if in_event { + let duration = if !duration_str.is_empty() { + parse_ics_duration(&duration_str).unwrap_or(0) + } else if !dtstart.is_empty() && !dtend.is_empty() { + calc_ics_duration_from_times(&dtstart, &dtend) + } else { + 0 + }; events.push(ParsedCalendarEvent { uid: if uid.is_empty() { None } else { Some(uid.clone()) }, - summary: if summary.is_empty() { - None - } else { - Some(summary.clone()) - }, + summary: if summary.is_empty() { None } else { Some(summary.clone()) }, start_time: parse_ics_datetime(&dtstart), end_time: parse_ics_datetime(&dtend), - location: if location.is_empty() { - None - } else { - Some(location.clone()) - }, + location: if location.is_empty() { None } else { Some(location.clone()) }, + description: if description.is_empty() { None } else { Some(description.clone()) }, + duration, }); } in_event = false; @@ -2375,6 +2883,12 @@ fn parse_ics_content(content: &str) -> Vec { } } else if let Some(val) = line.strip_prefix("LOCATION:") { location = val.to_string(); + } else if let Some(val) = line.strip_prefix("DESCRIPTION:") { + description = val.replace("\\n", "\n").replace("\\,", ","); + } else if line.starts_with("DURATION") { + if let Some(idx) = line.find(':') { + duration_str = line[idx + 1..].to_string(); + } } } } @@ -2485,15 +2999,17 @@ pub fn import_ics_file( } conn.execute( - "INSERT INTO calendar_events (source_id, uid, summary, start_time, end_time, duration, location, synced_at) - VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7)", + "INSERT INTO calendar_events (source_id, uid, summary, start_time, end_time, duration, location, description, synced_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ source_id, event.uid, event.summary, event.start_time, event.end_time, + event.duration, event.location, + event.description, now ], ) @@ -2710,6 +3226,33 @@ pub fn get_time_entries_paginated( }) } +#[tauri::command] +pub fn search_entries(state: State, query: String, limit: Option) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let limit = limit.unwrap_or(10); + let pattern = format!("%{}%", query); + let mut stmt = conn.prepare( + "SELECT te.id, te.project_id, te.description, te.start_time, te.duration, p.name as project_name, p.color as project_color + FROM time_entries te + LEFT JOIN projects p ON te.project_id = p.id + WHERE te.description LIKE ?1 + ORDER BY te.start_time DESC + LIMIT ?2" + ).map_err(|e| e.to_string())?; + let rows = stmt.query_map(params![pattern, limit], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "project_id": row.get::<_, i64>(1)?, + "description": row.get::<_, Option>(2)?, + "start_time": row.get::<_, String>(3)?, + "duration": row.get::<_, i64>(4)?, + "project_name": row.get::<_, Option>(5)?, + "project_color": row.get::<_, Option>(6)?, + })) + }).map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) +} + #[tauri::command] pub fn bulk_delete_entries(state: State, ids: Vec) -> Result<(), String> { if ids.is_empty() { return Ok(()); } @@ -2718,6 +3261,7 @@ pub fn bulk_delete_entries(state: State, ids: Vec) -> Result<(), let result = (|| -> Result<(), rusqlite::Error> { for id in &ids { + conn.execute("DELETE FROM invoice_items WHERE time_entry_id = ?1", params![id])?; conn.execute("DELETE FROM entry_tags WHERE entry_id = ?1", params![id])?; conn.execute("DELETE FROM time_entries WHERE id = ?1", params![id])?; } @@ -2852,6 +3396,23 @@ pub fn delete_entry_template(state: State, id: i64) -> Result<(), Stri Ok(()) } +#[tauri::command] +pub fn update_entry_template(state: State, template: serde_json::Value) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + let id = template.get("id").and_then(|v| v.as_i64()).ok_or("id required")?; + let name = template.get("name").and_then(|v| v.as_str()).unwrap_or("Untitled"); + let project_id = template.get("project_id").and_then(|v| v.as_i64()).ok_or("project_id required")?; + let task_id = template.get("task_id").and_then(|v| v.as_i64()); + let description = template.get("description").and_then(|v| v.as_str()); + let duration = template.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); + let billable = template.get("billable").and_then(|v| v.as_i64()).unwrap_or(1); + conn.execute( + "UPDATE entry_templates SET name=?1, project_id=?2, task_id=?3, description=?4, duration=?5, billable=?6 WHERE id=?7", + params![name, project_id, task_id, description, duration, billable, id], + ).map_err(|e| e.to_string())?; + Ok(()) +} + #[tauri::command] pub fn get_timesheet_rows(state: State, week_start: String) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; @@ -3080,3 +3641,10 @@ fn get_default_templates() -> Vec { }, ] } + +#[tauri::command] +pub fn seed_sample_data(state: State) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + crate::seed::seed(&conn)?; + Ok("Sample data loaded".to_string()) +} diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index a9a3586..5ba9509 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -19,6 +19,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { "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, []) { @@ -52,6 +53,8 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { "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, []) { @@ -76,9 +79,10 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; - // Migrate tasks table - add estimated_hours column (safe to re-run) + // 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, []) { @@ -302,6 +306,22 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + // 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, @@ -345,6 +365,38 @@ 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 ('hourly_rate', '50')", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d24979a..fd431e2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ use tauri::Manager; mod database; mod commands; mod os_detection; +mod seed; pub struct AppState { pub db: Mutex, @@ -135,10 +136,26 @@ pub fn run() { 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, + commands::seed_sample_data, ]) .setup(|app| { #[cfg(desktop)] @@ -151,6 +168,7 @@ pub fn run() { let menu = Menu::with_items(app, &[&show, &quit])?; let _tray = TrayIconBuilder::new() + .icon(app.default_window_icon().unwrap().clone()) .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(|app, event| { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a530cec..f127082 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,5 +4,5 @@ )] fn main() { - local_time_tracker_lib::run(); + zeroclock_lib::run(); } diff --git a/src-tauri/src/os_detection.rs b/src-tauri/src/os_detection.rs new file mode 100644 index 0000000..dc34316 --- /dev/null +++ b/src-tauri/src/os_detection.rs @@ -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, +} + +pub fn get_system_idle_seconds() -> u64 { + unsafe { + let mut info = LASTINPUTINFO { + cbSize: std::mem::size_of::() 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 { + 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, + 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 { + 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 { + 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 { + unsafe { + let wide: Vec = 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::() 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, u32, u32)> { + if hbm_color.is_invalid() { + return None; + } + + let mut bm = BITMAP::default(); + if GetObjectW( + hbm_color, + std::mem::size_of::() 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::() 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> { + 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 +} diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs new file mode 100644 index 0000000..2f07e58 --- /dev/null +++ b/src-tauri/src/seed.rs @@ -0,0 +1,672 @@ +use rusqlite::Connection; + +fn hash(n: u32) -> u32 { + let x = n.wrapping_mul(2654435761); + let y = (x ^ (x >> 16)).wrapping_mul(2246822519); + y ^ (y >> 13) +} + +fn offset_to_ymd(offset: u32) -> (u32, u32, u32) { + static MONTHS: [(u32, u32, u32); 12] = [ + (2025, 3, 31), + (2025, 4, 30), + (2025, 5, 31), + (2025, 6, 30), + (2025, 7, 31), + (2025, 8, 31), + (2025, 9, 30), + (2025, 10, 31), + (2025, 11, 30), + (2025, 12, 31), + (2026, 1, 31), + (2026, 2, 28), + ]; + let mut rem = offset; + for &(y, m, d) in &MONTHS { + if rem < d { + return (y, m, rem + 1); + } + rem -= d; + } + (2026, 2, 28) +} + +struct ProjPeriod { + project_id: i64, + task_ids: &'static [i64], + desc_pool: usize, + start_day: u32, + end_day: u32, + billable: i64, +} + +static PROJ_PERIODS: &[ProjPeriod] = &[ + ProjPeriod { project_id: 1, task_ids: &[1, 2, 3, 4], desc_pool: 0, start_day: 2, end_day: 75, billable: 1 }, + ProjPeriod { project_id: 2, task_ids: &[5, 6, 7, 8], desc_pool: 1, start_day: 2, end_day: 155, billable: 1 }, + ProjPeriod { project_id: 3, task_ids: &[9, 10, 11, 12], desc_pool: 1, start_day: 33, end_day: 122, billable: 1 }, + ProjPeriod { project_id: 4, task_ids: &[13, 14, 15, 16], desc_pool: 0, start_day: 63, end_day: 183, billable: 1 }, + ProjPeriod { project_id: 5, task_ids: &[17, 18], desc_pool: 7, start_day: 2, end_day: 356, billable: 0 }, + ProjPeriod { project_id: 6, task_ids: &[19, 20, 21], desc_pool: 3, start_day: 93, end_day: 155, billable: 1 }, + ProjPeriod { project_id: 7, task_ids: &[22, 23, 24, 25], desc_pool: 5, start_day: 122, end_day: 183, billable: 1 }, + ProjPeriod { project_id: 8, task_ids: &[26, 27, 28, 29], desc_pool: 0, start_day: 155, end_day: 214, billable: 1 }, + ProjPeriod { project_id: 9, task_ids: &[30, 31, 32, 33], desc_pool: 4, start_day: 184, end_day: 244, billable: 1 }, + ProjPeriod { project_id: 10, task_ids: &[34, 35, 36], desc_pool: 1, start_day: 214, end_day: 244, billable: 1 }, + ProjPeriod { project_id: 11, task_ids: &[37, 38, 39, 40], desc_pool: 5, start_day: 214, end_day: 275, billable: 1 }, + ProjPeriod { project_id: 12, task_ids: &[41, 42, 43], desc_pool: 3, start_day: 245, end_day: 336, billable: 1 }, + ProjPeriod { project_id: 13, task_ids: &[44, 45, 46, 47], desc_pool: 1, start_day: 275, end_day: 356, billable: 1 }, + ProjPeriod { project_id: 14, task_ids: &[48, 49, 50], desc_pool: 1, start_day: 306, end_day: 356, billable: 1 }, + ProjPeriod { project_id: 15, task_ids: &[51, 52], desc_pool: 0, start_day: 306, end_day: 336, billable: 1 }, + ProjPeriod { project_id: 16, task_ids: &[53, 54, 55], desc_pool: 1, start_day: 306, end_day: 356, billable: 1 }, + ProjPeriod { project_id: 17, task_ids: &[56, 57, 58], desc_pool: 1, start_day: 93, end_day: 356, billable: 1 }, + ProjPeriod { project_id: 18, task_ids: &[59, 60], desc_pool: 7, start_day: 214, end_day: 244, billable: 0 }, +]; + +static DESC_POOLS: &[&[&str]] = &[ + // 0: Logo/brand + &[ + "Concept sketches - exploring directions", + "Color palette tests on paper", + "Wordmark spacing and kerning", + "Symbol refinement - tightening curves", + "Client presentation deck", + "Applying revision notes from call", + "Final vector cleanup and export", + "Brand guidelines page layout", + "Moodboard assembly", + "Scanning hand-drawn letterforms", + ], + // 1: Illustration + &[ + "Thumbnail compositions", + "Reference gathering and mood board", + "Rough pencil sketches", + "Inking line art", + "Flat color blocking", + "Rendering pass - light and shadow", + "Background and texture work", + "Final cleanup and detail pass", + "Scanning and color correction", + "Exploring alternate compositions", + "Detail work on foreground elements", + "Adding halftone textures", + ], + // 2: Typography/layout + &[ + "Typography pairing tests", + "Page layout drafts", + "Grid and margin adjustments", + "Hierarchy and scale refinement", + "Print proof review", + "Spread layout and flow", + ], + // 3: Web/digital + &[ + "Wireframe sketches on paper", + "Homepage hero illustration", + "Responsive layout mockups", + "Icon set - first batch", + "Custom divider illustrations", + "Gallery page layout", + "Color theme adjustments for screen", + "Asset export for dev handoff", + ], + // 4: Packaging + &[ + "Die-cut template measurements", + "Repeating pattern tile design", + "Label artwork - front panel", + "Box mockup rendering", + "Press-ready PDF export", + "Color proofing adjustments", + "Tissue paper pattern", + "Sticker sheet layout", + ], + // 5: Book cover + &[ + "Reading manuscript excerpts for feel", + "Cover thumbnail sketches", + "Main illustration - rough draft", + "Color composition study", + "Title lettering and spine layout", + "Full cover rendering", + "Back cover synopsis layout", + "Author photo placement and bio", + ], + // 6: Meeting/admin + &[ + "Client call - project kickoff", + "Reviewing feedback document", + "Scope and timeline email", + "Invoice prep and send", + "File organization and archiving", + ], + // 7: Personal + &[ + "Selecting portfolio pieces", + "Writing case study notes", + "Photographing finished prints", + "Updating website gallery", + "Sketching for fun", + "Ink drawing - daily prompt", + "Scanning and posting work", + "Reorganizing reference library", + ], +]; + +pub fn seed(conn: &Connection) -> Result<(), String> { + let e = |err: rusqlite::Error| err.to_string(); + + conn.execute_batch( + "PRAGMA foreign_keys = OFF; + DELETE FROM entry_tags; + DELETE FROM invoice_payments; + DELETE FROM invoice_items; + DELETE FROM recurring_invoices; + DELETE FROM invoices; + DELETE FROM favorites; + DELETE FROM recurring_entries; + DELETE FROM entry_templates; + DELETE FROM timesheet_rows; + DELETE FROM timesheet_locks; + DELETE FROM timeline_events; + DELETE FROM expenses; + DELETE FROM tracked_apps; + DELETE FROM time_entries; + DELETE FROM tasks; + DELETE FROM projects; + DELETE FROM clients; + DELETE FROM tags; + DELETE FROM calendar_events; + DELETE FROM calendar_sources; + DELETE FROM sqlite_sequence; + PRAGMA foreign_keys = ON;", + ) + .map_err(e)?; + + // ========================================== + // CLIENTS + // ========================================== + conn.execute_batch( + "INSERT INTO clients (id, name, email, company, phone, payment_terms, notes) VALUES + (1, 'Anna Kowalski', 'anna@moonlightbakery.com', 'Moonlight Bakery', '555-0142', 'net_30', 'Longtime client. Loves warm earth tones and hand-drawn feel.'), + (2, 'James Okonkwo', 'james@riverandstone.com', 'River & Stone Pottery', '555-0238', 'net_15', 'Prefers email. Needs high-res for print catalog.'), + (3, 'Rosa Delgado', 'rosa@velvetsparrow.com', 'The Velvet Sparrow', '555-0319', 'net_30', 'Band manager. Quick feedback, clear direction.'), + (4, 'Tom Brennan', 'tom@fernandwillow.com', 'Fern & Willow Cafe', '555-0421', 'net_30', 'Very responsive. The cafe on Elm St has great coffee.'), + (5, 'Marcus Chen', 'marcus@marcuschen.com', NULL, '555-0517', 'due_on_receipt', 'Photographer. Good referral source.'), + (6, 'Diane Huang', 'diane@wildfieldpress.com', 'Wildfield Press', '555-0634', 'net_45', 'Publisher - steady ongoing work. Pays reliably.'), + (7, 'Kai Nishimura', 'kai@sableandco.com', 'Sable & Co Tattoo', '555-0728', 'net_15', 'Expects fast turnaround. Loves bold linework.');", + ) + .map_err(e)?; + + // ========================================== + // PROJECTS + // ========================================== + conn.execute_batch( + "INSERT INTO projects (id, client_id, name, hourly_rate, color, archived, budget_hours, notes) VALUES + (1, 1, 'Moonlight Logo Redesign', 65, '#F59E0B', 1, 50, 'Modernizing the logo. Keep the crescent moon motif.'), + (2, 2, 'Product Catalog', 70, '#8B5CF6', 1, 130, '48-page catalog for spring/summer pottery collection.'), + (3, 3, 'Album Cover - Quiet Hours', 75, '#EF4444', 1, 65, 'Debut album. Dreamy watercolor feel, night sky theme.'), + (4, 4, 'Fern & Willow Rebrand', 70, '#10B981', 1, 110, 'Full rebrand - logo, menu boards, signage, socials.'), + (5, NULL, 'Portfolio Update', 0, '#6B7280', 0, NULL, 'Ongoing portfolio maintenance and case studies.'), + (6, 5, 'Portfolio Website', 60, '#3B82F6', 1, 55, 'Custom illustrations for photography portfolio.'), + (7, 6, 'Tide Pool Dreams - Cover', 75, '#06B6D4', 1, 60, 'Middle-grade novel cover. Lush underwater scene.'), + (8, 7, 'Sable & Co Brand Kit', 80, '#A855F7', 1, 55, 'Full identity - logo, cards, signage, flash sheet.'), + (9, 1, 'Seasonal Packaging', 60, '#EC4899', 1, 70, 'Holiday gift box designs and labels.'), + (10, 3, 'Tour Poster - West Coast', 60, '#DC2626', 1, 35, 'Screenprint poster for 12-city tour.'), + (11, 6, 'Moth & Lantern - Cover', 75, '#0EA5E9', 1, 60, 'YA fantasy novel cover. Moths, lantern light, forest.'), + (12, 2, 'Website Illustrations', 65, '#6366F1', 0, 85, 'Custom spot illustrations for new e-commerce site.'), + (13, 4, 'Mural Design', 65, '#34D399', 0, 75, 'Interior mural - botanical garden theme, 8ft x 12ft.'), + (14, 1, 'Menu Illustrations', 55, '#F97316', 0, 45, 'Hand-drawn food illos for seasonal menu refresh.'), + (15, 5, 'Business Cards', 50, '#60A5FA', 1, 18, 'Custom illustrated business card with foil stamp.'), + (16, 3, 'Merch Designs', 55, '#F43F5E', 0, 40, 'T-shirt, sticker, and tote bag art for online store.'), + (17, 6, 'Monthly Spot Illustrations', 50, '#14B8A6', 0, 100, 'Recurring spot illos for chapter headers in books.'), + (18, NULL, 'Inktober 2025', 0, '#1F2937', 1, NULL, 'Personal daily ink drawing challenge.');", + ) + .map_err(e)?; + + // ========================================== + // TASKS (60 tasks across 18 projects) + // ========================================== + conn.execute_batch( + "INSERT INTO tasks (id, project_id, name, estimated_hours) VALUES + (1, 1, 'Research', 8), + (2, 1, 'Sketching', 15), + (3, 1, 'Refinement', 15), + (4, 1, 'Final Delivery', 10), + (5, 2, 'Photography Layout', 30), + (6, 2, 'Illustration', 50), + (7, 2, 'Typography', 25), + (8, 2, 'Print Prep', 20), + (9, 3, 'Concept Art', 15), + (10, 3, 'Main Illustration', 25), + (11, 3, 'Lettering', 12), + (12, 3, 'File Prep', 8), + (13, 4, 'Brand Strategy', 15), + (14, 4, 'Logo Design', 35), + (15, 4, 'Collateral', 35), + (16, 4, 'Signage', 20), + (17, 5, 'Curation', NULL), + (18, 5, 'Photography', NULL), + (19, 6, 'Wireframes', 12), + (20, 6, 'Visual Design', 25), + (21, 6, 'Asset Creation', 15), + (22, 7, 'Reading', 8), + (23, 7, 'Sketches', 15), + (24, 7, 'Cover Art', 25), + (25, 7, 'Layout', 10), + (26, 8, 'Research', 10), + (27, 8, 'Concepts', 15), + (28, 8, 'Refinement', 18), + (29, 8, 'Brand Kit', 12), + (30, 9, 'Template Setup', 10), + (31, 9, 'Pattern Design', 20), + (32, 9, 'Label Art', 25), + (33, 9, 'Press Files', 12), + (34, 10, 'Layout', 10), + (35, 10, 'Illustration', 18), + (36, 10, 'Print Prep', 5), + (37, 11, 'Reading', 8), + (38, 11, 'Sketches', 15), + (39, 11, 'Cover Art', 25), + (40, 11, 'Layout', 10), + (41, 12, 'Page Illustrations', 30), + (42, 12, 'Icon Set', 25), + (43, 12, 'Banner Art', 20), + (44, 13, 'Concept', 12), + (45, 13, 'Scale Drawing', 20), + (46, 13, 'Color Studies', 18), + (47, 13, 'Detail Work', 22), + (48, 14, 'Food Illustrations', 20), + (49, 14, 'Layout', 12), + (50, 14, 'Spot Art', 10), + (51, 15, 'Design', 12), + (52, 15, 'Print Prep', 5), + (53, 16, 'T-shirt Art', 15), + (54, 16, 'Sticker Designs', 12), + (55, 16, 'Tote Bag Art', 10), + (56, 17, 'Sketching', 35), + (57, 17, 'Inking', 35), + (58, 17, 'Coloring', 25), + (59, 18, 'Daily Prompts', NULL), + (60, 18, 'Scanning', NULL);", + ) + .map_err(e)?; + + // ========================================== + // TAGS + // ========================================== + conn.execute_batch( + "INSERT INTO tags (id, name, color) VALUES + (1, 'rush', '#EF4444'), + (2, 'revision', '#F59E0B'), + (3, 'pro-bono', '#10B981'), + (4, 'personal', '#6B7280'), + (5, 'concept', '#8B5CF6'), + (6, 'final', '#3B82F6'), + (7, 'meeting', '#EC4899'), + (8, 'admin', '#6366F1');", + ) + .map_err(e)?; + + // ========================================== + // TIME ENTRIES (generated) + // ========================================== + let mut stmt = conn + .prepare( + "INSERT INTO time_entries (project_id, task_id, description, start_time, end_time, duration, billable) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .map_err(e)?; + + let session_starts: [(u32, u32); 4] = [(9, 0), (11, 0), (13, 30), (16, 0)]; + let session_maxmins: [u32; 4] = [120, 150, 150, 120]; + + let mut entry_count: i64 = 0; + + for day_offset in 0u32..357 { + let dow = (6 + day_offset) % 7; + if dow == 0 || dow == 6 { + // Weekend: only Inktober gets weekend work + if day_offset >= 214 && day_offset <= 244 { + let h = hash(day_offset); + if h % 3 == 0 { + let (y, m, d) = offset_to_ymd(day_offset); + let date = format!("{:04}-{:02}-{:02}", y, m, d); + let di = (h / 7) as usize % DESC_POOLS[7].len(); + let ti = if h % 2 == 0 { 59i64 } else { 60 }; + let dur_mins = 30 + (h % 60); + let start = format!("{}T10:{:02}:00", date, h % 45); + let dur_secs = (dur_mins * 60) as i64; + let end_mins = 10 * 60 + (h % 45) + dur_mins; + let end = format!("{}T{:02}:{:02}:00", date, end_mins / 60, end_mins % 60); + stmt.execute(rusqlite::params![18i64, ti, DESC_POOLS[7][di], start, end, dur_secs, 0i64]).map_err(e)?; + entry_count += 1; + } + } + continue; + } + + let h = hash(day_offset); + // Skip ~5% of weekdays (sick/vacation) + if h % 20 == 0 { + continue; + } + + let (y, m, d) = offset_to_ymd(day_offset); + let date = format!("{:04}-{:02}-{:02}", y, m, d); + + // Collect active projects + let active: Vec<&ProjPeriod> = PROJ_PERIODS + .iter() + .filter(|p| day_offset >= p.start_day && day_offset <= p.end_day) + .filter(|p| { + // Personal/portfolio only shows up ~15% of days + if p.project_id == 5 { + return hash(day_offset.wrapping_mul(5)) % 7 == 0; + } + true + }) + .collect(); + + if active.is_empty() { + continue; + } + + let n_sessions = 2 + (h % 2) as usize; // 2-3 sessions + let n_sessions = n_sessions.min(active.len().max(2)); + + for s in 0..n_sessions { + if s >= 4 { + break; + } + let sh = hash(day_offset * 100 + s as u32); + let proj_idx = (sh as usize) % active.len(); + let proj = active[proj_idx]; + + let task_idx = (sh / 3) as usize % proj.task_ids.len(); + let task_id = proj.task_ids[task_idx]; + let pool = DESC_POOLS[proj.desc_pool]; + let desc_idx = (sh / 7) as usize % pool.len(); + let desc = pool[desc_idx]; + + let (base_h, base_m) = session_starts[s]; + let max_mins = session_maxmins[s]; + let dur_mins = 45 + sh % (max_mins - 44); + let start_offset_mins = (sh / 11) % 20; + let start_h = base_h + (base_m + start_offset_mins) / 60; + let start_m = (base_m + start_offset_mins) % 60; + let end_total = start_h * 60 + start_m + dur_mins; + let end_h = end_total / 60; + let end_m = end_total % 60; + + if end_h >= 19 { + continue; + } + + let start = format!("{}T{:02}:{:02}:00", date, start_h, start_m); + let end = format!("{}T{:02}:{:02}:00", date, end_h, end_m); + let dur_secs = (dur_mins * 60) as i64; + + stmt.execute(rusqlite::params![ + proj.project_id, + task_id, + desc, + start, + end, + dur_secs, + proj.billable + ]) + .map_err(e)?; + entry_count += 1; + } + + // Occasional admin/meeting entry (~20% of days) + if h % 5 == 0 && !active.is_empty() { + let sh = hash(day_offset * 200); + let proj = active[0]; + let admin_descs = DESC_POOLS[6]; + let di = (sh / 3) as usize % admin_descs.len(); + let dur_mins = 15 + sh % 30; + let start = format!("{}T08:{:02}:00", date, 30 + sh % 25); + let end_total_mins = 8 * 60 + 30 + (sh % 25) + dur_mins; + let end = format!( + "{}T{:02}:{:02}:00", + date, + end_total_mins / 60, + end_total_mins % 60 + ); + stmt.execute(rusqlite::params![ + proj.project_id, + proj.task_ids[0], + admin_descs[di], + start, + end, + (dur_mins * 60) as i64, + proj.billable + ]) + .map_err(e)?; + entry_count += 1; + } + } + + drop(stmt); + + // ========================================== + // ENTRY TAGS (tag ~15% of entries) + // ========================================== + let total_entries = entry_count; + let tag_assignments: Vec<(i64, i64)> = (1..=total_entries) + .filter_map(|id| { + let h = hash(id as u32 * 31); + if h % 7 != 0 { + return None; + } + let tag = match h % 40 { + 0..=5 => 1, // rush + 6..=15 => 2, // revision + 16..=20 => 5, // concept + 21..=28 => 6, // final + 29..=33 => 7, // meeting + 34..=37 => 8, // admin + _ => 2, // revision + }; + Some((id, tag)) + }) + .collect(); + + let mut tag_stmt = conn + .prepare("INSERT OR IGNORE INTO entry_tags (entry_id, tag_id) VALUES (?1, ?2)") + .map_err(e)?; + for (eid, tid) in &tag_assignments { + tag_stmt.execute(rusqlite::params![eid, tid]).map_err(e)?; + } + drop(tag_stmt); + + // ========================================== + // EXPENSES + // ========================================== + conn.execute_batch( + "INSERT INTO expenses (project_id, client_id, category, description, amount, date, invoiced) VALUES + -- Software subscriptions (monthly) + (5, NULL, 'software', 'Clip Studio Paint Pro - annual', 49.99, '2025-03-15', 0), + (5, NULL, 'software', 'Affinity Designer 2 license', 69.99, '2025-04-02', 0), + (5, NULL, 'software', 'Dropbox Plus - annual renewal', 119.88, '2025-06-01', 0), + (5, NULL, 'software', 'Squarespace portfolio site - annual', 192.00, '2025-07-15', 0), + (17, 6, 'software', 'Font license - Recoleta family', 45.00, '2025-08-20', 1), + + -- Art supplies + (1, 1, 'supplies', 'Copic markers (12 pack, warm grays)', 89.99, '2025-03-08', 0), + (3, 3, 'supplies', 'Winsor & Newton watercolor set', 124.50, '2025-04-10', 0), + (2, 2, 'supplies', 'A3 hot press watercolor paper (50 sheets)', 42.00, '2025-05-05', 0), + (4, 4, 'supplies', 'Posca paint markers (8 pack)', 34.99, '2025-06-18', 0), + (18, NULL,'supplies', 'India ink - Sumi (3 bottles)', 27.50, '2025-10-01', 0), + (18, NULL,'supplies', 'Micron pen set (8 widths)', 22.99, '2025-10-03', 0), + (13, 4, 'supplies', 'Acrylic paint (mural - bulk order)', 187.00, '2025-12-20', 0), + (14, 1, 'supplies', 'Brush pen set for menu illos', 18.50, '2026-01-12', 0), + + -- Printing + (2, 2, 'printing', 'Test prints - catalog spreads', 85.00, '2025-07-22', 1), + (9, 1, 'printing', 'Packaging prototypes (6 units)', 120.00, '2025-10-15', 1), + (10, 3, 'printing', 'Poster screenprint run (50 copies)', 275.00, '2025-11-01', 1), + (15, 5, 'printing', 'Business card print run (250)', 65.00, '2026-01-28', 1), + + -- Reference materials + (5, NULL, 'other', 'Illustration annual 2025', 38.00, '2025-04-22', 0), + (5, NULL, 'other', 'Color and Light by James Gurney', 28.50, '2025-05-30', 0), + (7, 6, 'other', 'Marine biology reference photos (stock)', 29.00, '2025-07-10', 1), + + -- Travel + (4, 4, 'travel', 'Bus pass - client site visits (monthly)', 45.00, '2025-06-01', 0), + (4, 4, 'travel', 'Bus pass - client site visits (monthly)', 45.00, '2025-07-01', 0), + (8, 7, 'travel', 'Transit to tattoo parlor for measurements', 8.50, '2025-08-12', 0), + (13, 4, 'travel', 'Transit to cafe for mural measurements', 8.50, '2025-12-15', 0), + (13, 4, 'travel', 'Transit to cafe - mural install day', 8.50, '2026-02-10', 0), + + -- Equipment + (5, NULL, 'equipment', 'Tablet screen protector replacement', 24.99, '2025-09-05', 0), + (5, NULL, 'equipment', 'Desk lamp (daylight bulb)', 45.00, '2025-11-20', 0);", + ) + .map_err(e)?; + + // ========================================== + // INVOICES + // ========================================== + conn.execute_batch( + "INSERT INTO invoices (id, client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status) VALUES + (1, 1, 'INV-2025-001', '2025-05-28', '2025-06-27', 3120.00, 0, 0, 0, 3120.00, 'Logo redesign - concept through final delivery', 'paid'), + (2, 2, 'INV-2025-002', '2025-06-15', '2025-06-30', 4550.00, 0, 0, 0, 4550.00, 'Product catalog - first milestone (layout and illustrations)', 'paid'), + (3, 3, 'INV-2025-003', '2025-06-30', '2025-07-30', 4500.00, 0, 0, 0, 4500.00, 'Album cover art - Quiet Hours', 'paid'), + (4, 4, 'INV-2025-004', '2025-07-31', '2025-08-30', 3850.00, 0, 0, 0, 3850.00, 'Rebrand milestone 1 - logo and primary collateral', 'paid'), + (5, 2, 'INV-2025-005', '2025-08-15', '2025-08-30', 3850.00, 0, 0, 0, 3850.00, 'Product catalog - final milestone (print prep)', 'paid'), + (6, 5, 'INV-2025-006', '2025-08-20', '2025-08-20', 3000.00, 0, 0, 0, 3000.00, 'Portfolio website illustrations', 'paid'), + (7, 6, 'INV-2025-007', '2025-09-10', '2025-10-25', 4125.00, 0, 0, 0, 4125.00, 'Tide Pool Dreams - cover art', 'paid'), + (8, 4, 'INV-2025-008', '2025-09-30', '2025-10-30', 3500.00, 0, 0, 0, 3500.00, 'Rebrand milestone 2 - signage and social templates', 'paid'), + (9, 7, 'INV-2025-009', '2025-10-20', '2025-11-04', 4160.00, 0, 0, 0, 4160.00, 'Sable & Co - full brand kit', 'paid'), + (10, 6, 'INV-2025-010', '2025-09-30', '2025-11-14', 2250.00, 0, 0, 0, 2250.00, 'Monthly spot illustrations - Q3 (Jul-Sep)', 'overdue'), + (11, 1, 'INV-2025-011', '2025-11-25', '2025-12-25', 3780.00, 0, 0, 0, 3780.00, 'Seasonal packaging - holiday gift line', 'paid'), + (12, 3, 'INV-2025-012', '2025-11-20', '2025-12-20', 1860.00, 0, 0, 0, 1860.00, 'Tour poster - West Coast (design + print mgmt)', 'paid'), + (13, 6, 'INV-2025-013', '2025-12-20', '2026-02-03', 4275.00, 0, 0, 0, 4275.00, 'Moth & Lantern - cover art', 'sent'), + (14, 6, 'INV-2025-014', '2025-12-31', '2026-02-14', 2500.00, 0, 0, 0, 2500.00, 'Monthly spot illustrations - Q4 (Oct-Dec)', 'sent'), + (15, 2, 'INV-2026-001', '2026-01-31', '2026-02-14', 4225.00, 0, 0, 0, 4225.00, 'Website illustrations - first half', 'sent'), + (16, 4, 'INV-2026-002', '2026-02-15', '2026-03-17', 2600.00, 0, 0, 0, 2600.00, 'Mural design - concept and scale drawing', 'draft'), + (17, 1, 'INV-2026-003', '2026-02-20', '2026-03-22', 1100.00, 0, 0, 0, 1100.00, 'Menu illustrations - in progress', 'draft'), + (18, 5, 'INV-2026-004', '2026-01-25', '2026-01-25', 750.00, 0, 0, 0, 750.00, 'Business card design and print coordination', 'paid');", + ) + .map_err(e)?; + + // ========================================== + // INVOICE ITEMS + // ========================================== + conn.execute_batch( + "INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount) VALUES + (1, 'Logo redesign - research and concepts', 12, 65, 780), + (1, 'Logo refinement and final artwork', 24, 65, 1560), + (1, 'Brand guidelines document', 12, 65, 780), + (2, 'Catalog layout - 24 spreads', 30, 70, 2100), + (2, 'Product illustrations', 35, 70, 2450), + (3, 'Album cover art - concept through final', 48, 75, 3600), + (3, 'File preparation and print variants', 12, 75, 900), + (4, 'Brand strategy and logo design', 35, 70, 2450), + (4, 'Collateral design (menu, cards, social)', 20, 70, 1400), + (5, 'Catalog - typography and print preparation', 25, 70, 1750), + (5, 'Final revisions and press files', 30, 70, 2100), + (6, 'Website illustrations and icons', 50, 60, 3000), + (7, 'Cover illustration - concept to final', 45, 75, 3375), + (7, 'Layout, spine, and back cover', 10, 75, 750), + (8, 'Signage designs (3 pieces)', 25, 70, 1750), + (8, 'Social media template set', 25, 70, 1750), + (9, 'Logo and brand identity development', 32, 80, 2560), + (9, 'Brand kit - cards, signage, flash style', 20, 80, 1600), + (10, 'Spot illustrations - July', 15, 50, 750), + (10, 'Spot illustrations - August', 15, 50, 750), + (10, 'Spot illustrations - September', 15, 50, 750), + (11, 'Packaging design - 4 box sizes', 40, 60, 2400), + (11, 'Label art and tissue paper pattern', 23, 60, 1380), + (12, 'Poster illustration and layout', 24, 60, 1440), + (12, 'Print management and color proofing', 7, 60, 420), + (13, 'Cover illustration - Moth & Lantern', 45, 75, 3375), + (13, 'Layout and final files', 12, 75, 900), + (14, 'Spot illustrations - October', 18, 50, 900), + (14, 'Spot illustrations - November', 16, 50, 800), + (14, 'Spot illustrations - December', 16, 50, 800), + (15, 'Page illustrations - 12 pieces', 40, 65, 2600), + (15, 'Icon set - first batch (20 icons)', 25, 65, 1625), + (16, 'Mural concept sketches', 15, 65, 975), + (16, 'Scale drawing and color studies', 25, 65, 1625), + (17, 'Food illustrations (8 of 15)', 20, 55, 1100), + (18, 'Business card design - illustration + layout', 12, 50, 600), + (18, 'Print coordination', 3, 50, 150);", + ) + .map_err(e)?; + + // ========================================== + // INVOICE PAYMENTS + // ========================================== + conn.execute_batch( + "INSERT INTO invoice_payments (invoice_id, amount, date, method, notes) VALUES + (1, 3120.00, '2025-06-20', 'bank_transfer', 'Paid in full'), + (2, 4550.00, '2025-06-28', 'bank_transfer', NULL), + (3, 4500.00, '2025-07-25', 'bank_transfer', NULL), + (4, 3850.00, '2025-08-28', 'bank_transfer', NULL), + (5, 3850.00, '2025-08-29', 'bank_transfer', NULL), + (6, 3000.00, '2025-08-20', 'bank_transfer', 'Paid same day'), + (7, 4125.00, '2025-10-22', 'bank_transfer', NULL), + (8, 3500.00, '2025-10-28', 'bank_transfer', NULL), + (9, 4160.00, '2025-11-02', 'bank_transfer', 'Paid early'), + (11, 3780.00, '2025-12-18', 'bank_transfer', NULL), + (12, 1860.00, '2025-12-15', 'bank_transfer', NULL), + (18, 750.00, '2026-01-25', 'bank_transfer', 'Paid on receipt');", + ) + .map_err(e)?; + + // ========================================== + // FAVORITES + // ========================================== + conn.execute_batch( + "INSERT INTO favorites (project_id, task_id, description, sort_order) VALUES + (13, 44, 'Mural concept work', 0), + (14, 48, 'Menu food illustrations', 1), + (17, 56, 'Monthly spot illo', 2), + (16, 53, 'Merch design session', 3);", + ) + .map_err(e)?; + + // ========================================== + // ENTRY TEMPLATES + // ========================================== + conn.execute_batch( + "INSERT INTO entry_templates (name, project_id, task_id, description, duration, billable) VALUES + ('Quick sketch session', 13, 44, 'Concept sketching', 5400, 1), + ('Spot illustration', 17, 57, 'Inking spot illustration', 7200, 1), + ('Portfolio photo session', 5, 18, 'Photographing prints', 3600, 0), + ('Menu illo', 14, 48, 'Food illustration', 5400, 1);", + ) + .map_err(e)?; + + // ========================================== + // TRACKED APPS + // ========================================== + conn.execute_batch( + "INSERT INTO tracked_apps (project_id, exe_name, display_name) VALUES + (13, 'clip_studio_paint.exe', 'Clip Studio Paint'), + (14, 'clip_studio_paint.exe', 'Clip Studio Paint'), + (12, 'affinity_designer.exe', 'Affinity Designer'), + (16, 'affinity_designer.exe', 'Affinity Designer');", + ) + .map_err(e)?; + + // ========================================== + // BUSINESS IDENTITY (for invoice previews) + // ========================================== + conn.execute_batch( + "INSERT OR REPLACE INTO settings (key, value) VALUES + ('business_name', 'Mika Sato Illustration'), + ('business_address', '47 Brush & Ink Lane\nPortland, OR 97205'), + ('business_email', 'hello@mikasato.art'), + ('business_phone', '(503) 555-0147'), + ('hourly_rate', '95');", + ) + .map_err(e)?; + + Ok(()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9099b1d..8b778d4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "LocalTimeTracker", + "productName": "ZeroClock", "version": "1.0.0", "identifier": "com.localtimetracker.app", "build": { @@ -12,7 +12,7 @@ "app": { "windows": [ { - "title": "LocalTimeTracker", + "title": "ZeroClock", "width": 1200, "height": 800, "minWidth": 800, @@ -20,12 +20,21 @@ "decorations": false, "transparent": false, "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 } diff --git a/src/App.vue b/src/App.vue index d9c66e9..9531389 100644 --- a/src/App.vue +++ b/src/App.vue @@ -17,6 +17,8 @@ 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' @@ -26,6 +28,8 @@ 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 '' @@ -39,7 +43,10 @@ function getProjectColor(projectId?: number): string { 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() @@ -72,6 +79,8 @@ async function registerShortcuts() { }) } catch (e) { console.error('Failed to register shortcuts:', e) + } finally { + shortcutRegistering = false } } @@ -89,6 +98,39 @@ function applyTheme() { 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('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 @@ -184,15 +226,94 @@ onMounted(async () => { const invoicesStore = useInvoicesStore() await invoicesStore.fetchInvoices() - await invoicesStore.checkOverdue() + 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('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('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() - // Auto-backup on window close + 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 () => { + win.onCloseRequested(async (event) => { if (settingsStore.settings.auto_backup === 'true' && settingsStore.settings.backup_path) { try { await invoke('auto_backup', { backupDir: settingsStore.settings.backup_path }) @@ -200,6 +321,10 @@ onMounted(async () => { 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) @@ -320,4 +445,6 @@ watch(() => settingsStore.settings.persistent_notifications, (val) => {
{{ announcement }}
+ + diff --git a/src/components/AppCascadeDeleteDialog.vue b/src/components/AppCascadeDeleteDialog.vue new file mode 100644 index 0000000..bfa3cde --- /dev/null +++ b/src/components/AppCascadeDeleteDialog.vue @@ -0,0 +1,115 @@ + + + diff --git a/src/components/AppColorPicker.vue b/src/components/AppColorPicker.vue index 3c8ef5a..33819b2 100644 --- a/src/components/AppColorPicker.vue +++ b/src/components/AppColorPicker.vue @@ -2,6 +2,7 @@ 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 @@ -16,6 +17,8 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>() +const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap() + const isOpen = ref(false) const triggerRef = ref(null) const panelRef = ref(null) @@ -245,7 +248,7 @@ function onHuePointerUp() { function updatePosition() { if (!triggerRef.value) return - panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 260, estimatedHeight: 330 }) + panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 260, estimatedHeight: 330, panelEl: panelRef.value }) } // โ”€โ”€ Open / Close โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -261,15 +264,18 @@ function open() { 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) @@ -296,6 +302,34 @@ onBeforeUnmount(() => { 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() + } +} + + diff --git a/src/components/AppDiscardDialog.vue b/src/components/AppDiscardDialog.vue index f0a173d..7c38b09 100644 --- a/src/components/AppDiscardDialog.vue +++ b/src/components/AppDiscardDialog.vue @@ -1,33 +1,49 @@