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/docs/plans/2026-02-20-enhancement-round2-design.md b/docs/plans/2026-02-20-enhancement-round2-design.md deleted file mode 100644 index 384df49..0000000 --- a/docs/plans/2026-02-20-enhancement-round2-design.md +++ /dev/null @@ -1,415 +0,0 @@ -# ZeroClock Enhancement Round 2 - Design Document - -## Overview - -15 enhancements to existing ZeroClock functionality, balanced across polish/reliability, power-user productivity, and data/insights. All features WCAG 2.2 AAA compliant from the start. - -## Global WCAG 2.2 AAA Requirements (apply to every feature) - -- 7:1 minimum contrast ratio for all text against backgrounds -- All interactive elements have `focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent` -- All dialogs/popovers use `useFocusTrap`, `role="dialog"` or `role="alertdialog"`, `aria-modal="true"`, `aria-labelledby`/`aria-describedby` -- All dynamic content changes announced via `aria-live` regions -- All animations use `motion-safe:` prefix or respect the global `prefers-reduced-motion` override -- All information conveyed by color must also be conveyed by text, icon, or pattern (WCAG 1.4.1) -- All drag operations must have keyboard alternatives (WCAG 2.5.7) -- All timed interactions must be adjustable or disableable (WCAG 2.2.1) -- Design tokens used throughout: bg-bg-base, bg-bg-surface, bg-bg-elevated, text-text-primary, text-text-secondary, text-text-tertiary, border-border-subtle, accent, accent-hover - ---- - -## Phase 1: Polish and Reliability - -### 1. Smart Timer Safety Net - -**Problem:** Stopping the timer without a project selected silently discards tracked time. Timers left running for 8+ hours are likely accidental. - -**Enhancement:** -- When `timerStore.stop()` is called with no `selectedProjectId`, show a "Save Entry" dialog instead of discarding -- Dialog contains: project picker (AppSelect), description field, the tracked duration (read-only), Save and Discard buttons -- When stopping a timer with elapsed time > 8 hours, show a confirmation: "Timer has been running for X hours. Stop and save?" -- Both dialogs use focus trap, are keyboard-operable, and announce via aria-live - -**Files:** -- Modify: `src/stores/timer.ts` (stop method) -- Create: `src/components/TimerSaveDialog.vue` -- Modify: `src/App.vue` (mount the dialog) - -**Accessibility:** -- `role="alertdialog"` with focus trap -- `aria-live="assertive"` announcement when dialog opens: "Timer stopped. Select a project to save X hours of tracked time." -- Duration display uses `aria-label` with full spoken format ("2 hours and 34 minutes") - ---- - -### 2. Toast Auto-Dismiss with Undo - -**Problem:** Toasts persist indefinitely until manually closed or pushed out by newer ones. No way to undo destructive actions. - -**Enhancement:** -- Success toasts auto-dismiss after 4 seconds, error toasts after 8 seconds, info toasts after 6 seconds -- WCAG 2.2.1 compliance: toast timers pause on hover and on keyboard focus. Add a setting in General tab to disable auto-dismiss entirely ("Persistent notifications" toggle) -- Destructive operations (entry delete, expense delete, client delete, project delete) pass an `onUndo` callback to the toast store -- Toast with undo shows an "Undo" button. Clicking it calls the callback within the timeout window -- Undo button has `role="button"`, `aria-label="Undo delete"`, keyboard-accessible via Tab - -**Files:** -- Modify: `src/stores/toast.ts` (add duration, onUndo, pause/resume logic) -- Modify: `src/components/ToastNotification.vue` (auto-dismiss timer, undo button, hover/focus pause) -- Modify: `src/views/Settings.vue` (add persistent notifications toggle in General tab) - -**Accessibility:** -- Toast timer pauses on `:hover` and `:focus-within` (WCAG 2.2.1 Timing Adjustable) -- Undo button is keyboard-focusable and announced: "Undo available. Press to reverse." -- Screen reader announces remaining time: "Toast will dismiss in X seconds" -- User can disable auto-dismiss entirely in Settings - ---- - -### 3. Unified Error Handling with Retry - -**Problem:** Error handling is inconsistent across stores - some throw, some return null, some only console.error. Users rarely see actionable feedback. - -**Enhancement:** -- Create a shared `handleInvokeError(error, context)` utility that: - - Shows a toast with the error context ("Failed to save entry") - - For transient errors (connection, database busy), adds a "Retry" button to the toast - - Logs the full error to console for debugging -- Standardize all store catch blocks to use this utility -- The retry callback re-executes the failed operation - -**Files:** -- Create: `src/utils/errorHandler.ts` -- Modify: all 13 stores to use the shared handler in catch blocks - -**Accessibility:** -- Retry button in toast: `role="button"`, `aria-label="Retry failed operation"`, keyboard-accessible -- Error toasts use `role="alert"` (already the case) for immediate screen reader announcement -- After retry succeeds, announce "Operation completed successfully" via aria-live - ---- - -### 4. Onboarding Detection Resilience - -**Problem:** `detectCompletions()` in the onboarding store has all backend calls in a single try/catch - if `get_clients` fails, nothing else runs. No periodic re-check. - -**Enhancement:** -- Wrap each detection call in its own try/catch so failures are independent -- Add a 5-minute periodic re-check interval (started in App.vue alongside recurring entry checks) -- Clear the interval in onUnmounted - -**Files:** -- Modify: `src/stores/onboarding.ts` (individual try/catch per detection, no new UI) -- Modify: `src/App.vue` (add periodic re-check interval) - -**Accessibility:** -- No UI changes - this is purely backend resilience -- When an item transitions from incomplete to complete, the existing checklist UI already handles aria updates - ---- - -### 5. Invoice Items Save Reliability - -**Problem:** `saveInvoiceItems` creates items one at a time in a loop - if one fails, the rest are silently lost. No transaction rollback. - -**Enhancement:** -- Add a new Rust command `save_invoice_items_batch` that wraps all inserts in a single transaction (BEGIN/COMMIT/ROLLBACK) -- Replace the sequential loop in `invoicesStore.saveInvoiceItems` with a single call to the batch command -- On failure, show a clear error toast with retry option - -**Files:** -- Modify: `src-tauri/src/commands.rs` (add `save_invoice_items_batch` command) -- Modify: `src-tauri/src/lib.rs` (register command) -- Modify: `src/stores/invoices.ts` (replace loop with batch call) - -**Accessibility:** -- Error feedback via toast (already accessible) -- No new UI elements - ---- - -## Phase 2: Power User Productivity - -### 6. Global Quick Entry - -**Problem:** Logging a manual time entry requires navigating to Entries view and filling out a full form. No way to quickly capture time from wherever you are. - -**Enhancement:** -- Register a global keyboard shortcut (configurable, default Ctrl+Shift+N) via Tauri's global shortcut plugin -- Opens a compact floating dialog (Teleported to body, zoom-aware) with: - - Project picker (AppSelect) - - Task picker (AppSelect, filtered by project) - - Description text input - - Date picker (defaults to today) - - Duration input (supports H:MM and decimal formats) - - Tag picker (AppTagInput) - - Save and Cancel buttons -- Pre-fills with last-used project from settingsStore -- On save, creates entry via entriesStore.createEntry and shows success toast -- Dialog closes on Escape or Cancel - -**Files:** -- Create: `src/components/QuickEntryDialog.vue` -- Modify: `src/App.vue` (mount dialog, register shortcut) -- Modify: `src/views/Settings.vue` (add shortcut recorder for quick entry in Timer tab) - -**Accessibility:** -- `role="dialog"` with `aria-modal="true"`, `aria-label="Quick time entry"` -- Focus trap activated on open, first focusable element receives focus -- `aria-live="polite"` announces "Quick entry dialog opened" -- All form controls have visible labels (not just placeholders) -- Escape closes and restores focus to previously focused element -- `aria-keyshortcuts` attribute on the trigger describes the shortcut - ---- - -### 7. Entry Templates and Duplication - -**Problem:** Favorites only work for starting timers. No way to save and reuse manual entry configurations, and no quick duplicate for existing entries. - -**Enhancement:** -- Add "Save as Template" option when editing an entry (saves project + task + description + tags + duration to a new `entry_templates` table) -- Add "From Template" button in Entries view header that opens a picker dialog listing saved templates -- Selecting a template pre-fills the create entry form -- Add "Duplicate" button on each entry row in the entries table (creates a copy with today's date) -- Templates manageable (delete) from a section in Settings > Timer tab - -**Files:** -- Modify: `src-tauri/src/commands.rs` (add entry_templates CRUD commands + create table in database.rs) -- Modify: `src-tauri/src/lib.rs` (register commands) -- Create: `src/stores/entryTemplates.ts` -- Create: `src/components/EntryTemplatePicker.vue` -- Modify: `src/views/Entries.vue` (add From Template button, Duplicate button per row) -- Modify: `src/views/Settings.vue` (template management section) - -**Accessibility:** -- Template picker: `role="dialog"` with focus trap, `role="listbox"` for template list, `role="option"` per item -- Duplicate button: `aria-label="Duplicate entry for [project] on [date]"` -- "From Template" button: `aria-haspopup="dialog"` -- Template list items keyboard-navigable (ArrowUp/Down, Enter to select) -- Screen reader announces "Entry duplicated" or "Template applied" on action - ---- - -### 8. Timesheet Smart Row Persistence - -**Problem:** The timesheet view requires manually adding project/task rows each week. Starting a new week means rebuilding the same row configuration. - -**Enhancement:** -- When opening a week with no timesheet data, auto-populate rows from the previous week's row structure (projects + tasks, but with zero hours) -- Add a "Copy Last Week" button in the header that copies both structure and values -- Save row configuration per week in a `timesheet_rows` table so manually added rows persist across navigation -- "Copy Last Week" shows a confirmation dialog before overwriting any existing data - -**Files:** -- Modify: `src-tauri/src/commands.rs` (add `get_timesheet_rows`, `save_timesheet_rows`, `get_previous_week_structure` commands) -- Modify: `src-tauri/src/lib.rs` (register commands) -- Modify: `src/views/TimesheetView.vue` (auto-populate logic, copy button, persist rows) - -**Accessibility:** -- "Copy Last Week" button: `aria-label="Copy last week's timesheet structure"` -- Confirmation dialog for overwrite: `role="alertdialog"` with focus trap -- `aria-live="polite"` announces "Timesheet populated with X rows from last week" -- Auto-populated rows announced: "Loaded X project rows from previous week" - ---- - -### 9. Client Cascade Awareness - -**Problem:** Deleting a client has no cascade check or warning, unlike projects which now show dependent counts via AppCascadeDeleteDialog. - -**Enhancement:** -- Add `get_client_dependents` backend command that counts related projects, invoices, and expenses -- Add transactional cascade delete to `delete_client` (delete expenses, invoice items, invoices, tracked_apps, entry_tags, time_entries, favorites, recurring_entries, timeline_events, tasks, projects, then client) -- Wire AppCascadeDeleteDialog into Clients.vue delete flow (same pattern as Projects.vue) - -**Files:** -- Modify: `src-tauri/src/commands.rs` (add `get_client_dependents`, update `delete_client` to cascade) -- Modify: `src-tauri/src/lib.rs` (register command) -- Modify: `src/views/Clients.vue` (import AppCascadeDeleteDialog, add dependency check before delete) - -**Accessibility:** -- Reuses existing AppCascadeDeleteDialog which is already WCAG AAA compliant (focus trap, 3-second countdown, aria-live announcement, role="alertdialog") -- No new accessibility work needed - the existing component handles everything - ---- - -### 10. Expenses Receipt Management - -**Problem:** Expenses store a `receipt_path` but there's no way to view receipts inline, and attachment is manual file path entry only. - -**Enhancement:** -- Add inline receipt thumbnail on expense cards (small image preview if receipt_path exists) -- Add a lightbox component for full-size receipt viewing (focus trap, Escape to close, zoom controls) -- Add a file picker button for attaching receipts (uses Tauri's dialog plugin for native file picker - the keyboard-accessible alternative to drag-and-drop) -- Add drag-and-drop zone on the expense edit form as an additional attachment method -- Show a "No receipt" indicator (icon + text) on expenses without one -- Keyboard alternative for drag-and-drop: the file picker button serves as the primary accessible method (WCAG 2.5.7 Dragging Movements) - -**Files:** -- Create: `src/components/ReceiptLightbox.vue` -- Modify: `src/views/Entries.vue` (expense tab: add thumbnail, lightbox trigger, file picker, drop zone) - -**Accessibility:** -- Lightbox: `role="dialog"`, `aria-modal="true"`, `aria-label="Receipt image for [expense description]"`, focus trap, Escape closes -- Image: `alt="Receipt for [category] expense on [date], [amount]"` -- File picker button: `aria-label="Attach receipt file"` - this is the primary method, drag-and-drop is the enhancement (WCAG 2.5.7) -- Drop zone: `role="button"`, `aria-label="Drop receipt file here or press Enter to browse"`, keyboard-activatable (Enter/Space opens file picker) -- "No receipt" indicator uses both icon and text (not icon alone) -- Lightbox zoom controls: keyboard-accessible (+/- buttons with aria-labels) - ---- - -## Phase 3: Data and Insights - -### 11. Dashboard Weekly Comparison - -**Problem:** Dashboard shows current stats but gives no sense of trend - no way to know if this week is better or worse than last week. - -**Enhancement:** -- Add comparison indicators on each stat card: "+2.5h vs last week" or "-1.2h vs last week" with directional arrow icon -- Add a sparkline row below the weekly chart showing the last 4 weeks of daily totals (7 bars each, muted styling) -- Comparison data fetched via existing `get_reports` command with last week's date range - -**Files:** -- Modify: `src/views/Dashboard.vue` (add comparison computeds, sparkline rendering, fetch last week data) - -**Accessibility:** -- Comparison text is actual text, not just a colored arrow - e.g., "2.5 hours more than last week" or "1.2 hours less than last week" -- Color conveys reinforcement only (green for increase, red for decrease) - the text and arrow direction carry the meaning (WCAG 1.4.1) -- Arrow icon has `aria-hidden="true"` since the text already conveys direction -- Sparkline bars have a `role="img"` wrapper with `aria-label="Last 4 weeks: [week1] hours, [week2] hours, [week3] hours, [week4] hours"` -- Each individual sparkline bar does NOT need to be interactive - the summary label covers it -- Comparison values use `sr-only` prefix: "Compared to last week:" for screen readers - ---- - -### 12. Project Health Dashboard Cards - -**Problem:** Budget progress bars on project cards are basic - no at-a-glance health assessment combining pace, burn rate, and risk level. - -**Enhancement:** -- Add a health badge per project card combining budget burn rate and pace data from `get_project_budget_status` -- Three health states: "On Track" (green icon + text), "At Risk" (yellow icon + text), "Over Budget" (red icon + text) -- Health determined by: budget percentage > 100% = over budget, pace = "behind" and > 75% budget used = at risk, else on track -- Add an "Attention Needed" section at the top of Projects view listing at-risk and over-budget projects -- Each state uses BOTH a distinct icon (checkmark, warning triangle, x-circle) AND text label - never color alone - -**Files:** -- Modify: `src/views/Projects.vue` (add health badge computation, attention section) - -**Accessibility:** -- Health badge: icon + text label, never color alone (WCAG 1.4.1 Use of Color) -- Icons: CheckCircle for on-track, AlertTriangle for at-risk, XCircle for over-budget - each with `aria-hidden="true"` since text label is present -- Badge has `role="status"` so screen readers announce it -- Attention section: `role="region"` with `aria-label="Projects needing attention"`, `aria-live="polite"` so it updates when data changes -- Each attention item is a focusable link/button that navigates to the project -- Text labels: "On Track", "At Risk", "Over Budget" - clear, unambiguous, no jargon - ---- - -### 13. Reports Time-of-Day Heatmap - -**Problem:** Reports view has no visibility into when work happens - no pattern analysis of productive hours or workday distribution. - -**Enhancement:** -- Add a "Patterns" tab to Reports view -- Primary visualization: a 7x24 grid (days of week x hours of day) showing work intensity -- Each cell uses both color intensity AND a numeric label showing hours (e.g., "2.5h") - not color alone (WCAG 1.4.1) -- Cells with zero hours are empty (no false visual noise) -- Below the grid, add a "Data Table" toggle that switches to a fully accessible HTML table view of the same data (accessible alternative to the visual grid) -- Data computed from entry start_time timestamps within the selected date range - -**Files:** -- Modify: `src/views/Reports.vue` (add Patterns tab, heatmap grid, data table alternative) - -**Accessibility:** -- Grid uses `role="grid"` with `role="row"` and `role="gridcell"` for keyboard navigation -- Each cell has `aria-label="[Day] [Hour]: [X] hours"` (e.g., "Monday 9 AM: 2.5 hours") -- Arrow key navigation between cells (up/down/left/right) -- Color intensity reinforces the numeric values but is NOT the sole information channel - every cell shows its numeric value as text -- "Data Table" toggle button: `aria-pressed` toggle, switches between visual grid and `` with `
` and `` -- Table alternative is a standard HTML table with proper headers, fully navigable by screen readers -- Summary stats below the grid as text: "Most productive: Monday 9-10 AM (avg 1.8h)", "Quietest: Sunday (avg 0.2h)" -- `motion-safe:` on any cell hover/focus transitions - ---- - -### 14. Rounding Visibility and Preview - -**Problem:** Time rounding is configured in Settings but its effect is invisible - users can't see how much time is being rounded. - -**Enhancement:** -- On entry rows in Entries view: show a small "rounded" indicator next to duration when rounding changes the value, with tooltip showing "Actual: 1h 23m, Rounded: 1h 30m" -- On invoice line items: show both actual and rounded hours when rounding is active -- In Reports view Hours tab: add a "Rounding Impact" summary line showing total time added/subtracted by rounding across all entries in the date range -- Rounding calculations use the existing `roundDuration()` utility from `src/utils/rounding.ts` - -**Files:** -- Modify: `src/views/Entries.vue` (add rounded indicator on entry rows) -- Modify: `src/views/Invoices.vue` (show actual vs rounded on line items) -- Modify: `src/views/Reports.vue` (add rounding impact summary) - -**Accessibility:** -- Rounded indicator: not just a colored dot - shows text "Rounded" with a clock icon, `aria-label="Duration rounded from [actual] to [rounded]"` -- Tooltip: accessible via keyboard focus (not hover-only), uses `role="tooltip"` with `aria-describedby` -- Rounding impact summary: plain text, 7:1 contrast, e.g., "Rounding added 2h 15m across 47 entries" -- Actual vs. rounded on invoices: both values visible as text, difference highlighted with both color and "+Xm" / "-Xm" text label -- All diff indicators use 7:1 contrast ratios against their backgrounds - ---- - -### 15. Export Completeness and Scheduling - -**Problem:** `export_data` only exports clients, projects, entries, and invoices - missing tags, expenses, favorites, recurring entries, tracked apps, timeline events, calendar sources, and settings. No automated backup. - -**Enhancement:** -- Expand `export_data` to include ALL data: tags, entry_tags, expenses, favorites, recurring_entries, tracked_apps, timeline_events, calendar_sources, calendar_events, timesheet_locks, and settings -- Update `import_json_data` to handle the expanded format (backward compatible with old exports) -- Add a "Last exported" timestamp display in Settings > Data tab -- Add an "Auto-backup on close" toggle in Settings > Data tab that saves a backup JSON to a user-configured directory when the app closes -- Add a "Backup path" file picker (Tauri dialog) for choosing the auto-backup directory -- Backup files named with timestamp: `zeroclock-backup-YYYY-MM-DD.json` -- WCAG 2.2.1: the auto-backup happens on app close (user-initiated), not on a timer, so no timing concerns - -**Files:** -- Modify: `src-tauri/src/commands.rs` (expand `export_data`, update `import_json_data`, add `auto_backup` command) -- Modify: `src-tauri/src/lib.rs` (register commands) -- Modify: `src/views/Settings.vue` (add last-exported display, auto-backup toggle, path picker) -- Modify: `src/App.vue` (hook into Tauri window close event for auto-backup) - -**Accessibility:** -- File picker button: `aria-label="Choose backup directory"` -- Last exported display: `role="status"`, readable text "Last exported: February 20, 2026 at 3:45 PM" (not relative time like "2 hours ago") -- Auto-backup toggle: standard toggle with label, `aria-checked` -- Backup path shown as text with a "Change" button, not an editable text field -- All controls have visible labels and 7:1 contrast - ---- - -## Implementation Phases - -### Phase 1: Polish and Reliability (Features 1-5) -Mostly backend and store changes, minimal new UI. Low risk, high impact on data integrity. - -### Phase 2: Power User Productivity (Features 6-10) -Mix of new components and view modifications. Medium complexity, high impact on daily workflow. - -### Phase 3: Data and Insights (Features 11-15) -Primarily view-level changes with some backend expansion. Medium complexity, high impact on decision-making. - -## Verification Criteria - -Each feature must pass: -1. `npx vue-tsc --noEmit` - TypeScript type check -2. `npx vite build` - Production build -3. `cargo build` - Rust backend build (if backend changed) -4. Manual WCAG AAA spot check: - - All interactive elements keyboard-operable - - All dialogs trap focus and close with Escape - - All dynamic content announced via aria-live - - All form controls have visible labels - - No information conveyed by color alone - - 7:1 contrast on all text - - Drag operations have keyboard alternatives - - Timed interactions are adjustable diff --git a/docs/plans/2026-02-20-enhancement-round2-plan.md b/docs/plans/2026-02-20-enhancement-round2-plan.md deleted file mode 100644 index f876545..0000000 --- a/docs/plans/2026-02-20-enhancement-round2-plan.md +++ /dev/null @@ -1,1744 +0,0 @@ -# Enhancement Round 2 Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Implement 15 feature enhancements across polish/reliability, power-user productivity, and data/insights - all WCAG 2.2 AAA compliant. - -**Architecture:** Vue 3 Composition API with Pinia stores, Tauri v2 Rust backend with SQLite (rusqlite), Tailwind CSS v4 design tokens. No test framework - verification via `npx vue-tsc --noEmit`, `npx vite build`, and `cargo build`. - -**Tech Stack:** Vue 3, TypeScript, Pinia, Tauri v2, Rust, SQLite, Tailwind CSS v4, Chart.js, lucide-vue-next icons. - ---- - -## Context for the Implementer - -### Design tokens - -All UI uses semantic tokens: `bg-bg-base`, `bg-bg-surface`, `bg-bg-elevated`, `bg-bg-inset`, `text-text-primary`, `text-text-secondary`, `text-text-tertiary`, `border-border-subtle`, `border-border-visible`, `text-accent-text`, `bg-accent`, `bg-accent-hover`, `bg-accent-muted`, `text-status-running`, `text-status-error`, `text-status-warning`. - -### Typography pattern - -Labels: `text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em]` -Section titles: `text-[0.8125rem] text-text-primary` -Descriptions: `text-[0.6875rem] text-text-tertiary mt-0.5` -Headings: `text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary` - -### Settings pattern - -All settings stored as key-value strings via `settingsStore.updateSetting(key, value)`. Access via `settingsStore.settings.key_name`. - -### Focus visible pattern - -All interactive elements: `focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent` - -### Toggle switch pattern (used in Settings.vue) - -```html - -``` - -### Dialog pattern - -Overlay: `fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50` -Dialog body: `bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-xl p-6` -Attributes: `role="dialog" aria-modal="true" aria-labelledby="dialog-title-id"` -Import `useFocusTrap` from `../utils/focusTrap` - call `activate(el, { onDeactivate })` and `deactivate()`. - -### Announcer pattern - -```ts -import { useAnnouncer } from '../composables/useAnnouncer' -const { announce } = useAnnouncer() -announce('Message for screen reader') -``` - -### Backend command registration - -Add commands to `src-tauri/src/lib.rs` in the `generate_handler!` macro (lines 43-133). Add new function in `src-tauri/src/commands.rs`. Database migrations in `src-tauri/src/database.rs`. - -### Verification commands - -After every task: `npx vue-tsc --noEmit && npx vite build`. After Rust changes: `cd src-tauri && cargo build`. - ---- - -## Phase 1: Polish and Reliability (Features 1-5) - ---- - -### Task 1: Toast auto-dismiss with undo - store changes - -**Files:** -- Modify: `src/stores/toast.ts` - -**Step 1:** Read `src/stores/toast.ts` (42 lines). - -**Step 2:** Replace the entire file with the enhanced version. Changes: -- Add `duration`, `onUndo`, `paused`, and `timerId` fields to `Toast` interface -- Add auto-dismiss logic with configurable timeouts per type -- Add `pauseToast(id)` and `resumeToast(id)` functions -- Read persistent_notifications setting from settingsStore - -```ts -import { defineStore } from 'pinia' -import { ref } from 'vue' - -export interface Toast { - id: number - message: string - type: 'success' | 'error' | 'info' - exiting?: boolean - duration: number - onUndo?: () => void - paused?: boolean - timerId?: number -} - -const DURATIONS: Record = { - success: 4000, - error: 8000, - info: 6000, -} - -export const useToastStore = defineStore('toast', () => { - const toasts = ref([]) - let nextId = 0 - let persistentNotifications = false - - function setPersistentNotifications(val: boolean) { - persistentNotifications = val - } - - function addToast(message: string, type: Toast['type'] = 'info', options?: { onUndo?: () => void; duration?: number }) { - const id = nextId++ - const duration = options?.duration ?? DURATIONS[type] - const toast: Toast = { id, message, type, duration, onUndo: options?.onUndo } - toasts.value.push(toast) - - if (!persistentNotifications) { - startDismissTimer(toast) - } - - // Cap at 5, oldest gets exiting state - if (toasts.value.length > 5) { - const oldest = toasts.value.find(t => !t.exiting) - if (oldest) removeToast(oldest.id) - } - } - - function startDismissTimer(toast: Toast) { - if (toast.timerId) clearTimeout(toast.timerId) - toast.timerId = window.setTimeout(() => { - removeToast(toast.id) - }, toast.duration) - } - - function pauseToast(id: number) { - const toast = toasts.value.find(t => t.id === id) - if (toast && toast.timerId) { - clearTimeout(toast.timerId) - toast.timerId = undefined - toast.paused = true - } - } - - function resumeToast(id: number) { - const toast = toasts.value.find(t => t.id === id) - if (toast && toast.paused && !persistentNotifications) { - toast.paused = false - startDismissTimer(toast) - } - } - - function undoToast(id: number) { - const toast = toasts.value.find(t => t.id === id) - if (toast?.onUndo) { - toast.onUndo() - removeToast(id) - } - } - - function removeToast(id: number) { - const toast = toasts.value.find(t => t.id === id) - if (toast) { - if (toast.timerId) clearTimeout(toast.timerId) - toast.exiting = true - setTimeout(() => { - toasts.value = toasts.value.filter(t => t.id !== id) - }, 150) - } - } - - function success(message: string, options?: { onUndo?: () => void }) { addToast(message, 'success', options) } - function error(message: string, options?: { onUndo?: () => void }) { addToast(message, 'error', options) } - function info(message: string, options?: { onUndo?: () => void }) { addToast(message, 'info', options) } - - return { toasts, addToast, removeToast, pauseToast, resumeToast, undoToast, success, error, info, setPersistentNotifications } -}) -``` - -**Step 3:** Verify: `npx vue-tsc --noEmit` - -**Step 4:** Commit: `git add src/stores/toast.ts && git commit -m "feat: toast auto-dismiss with undo and pause support"` - ---- - -### Task 2: Toast component - auto-dismiss UI, undo button, hover/focus pause - -**Files:** -- Modify: `src/components/ToastNotification.vue` - -**Step 1:** Read `src/components/ToastNotification.vue` (46 lines). - -**Step 2:** Replace with enhanced version that adds: -- `@mouseenter` / `@mouseleave` for hover pause/resume -- `@focusin` / `@focusout` for keyboard focus pause/resume -- Undo button when `toast.onUndo` exists -- Progress bar showing remaining time (visual only, not critical info) - -```vue - - - -``` - -**Step 3:** Verify: `npx vue-tsc --noEmit` - -**Step 4:** Commit: `git add src/components/ToastNotification.vue && git commit -m "feat: toast undo button and hover/focus pause"` - ---- - -### Task 3: Toast persistent notifications setting - -**Files:** -- Modify: `src/views/Settings.vue` (General tab, after "Getting Started checklist" section around line 349) -- Modify: `src/App.vue` (initialize persistent setting on mount) - -**Step 1:** Read `src/views/Settings.vue` lines 315-355 and `src/App.vue` lines 94-140. - -**Step 2:** In Settings.vue, add a `persistentNotifications` ref and toggle after the "Getting Started checklist" section (after line 349, before `` of general tab at line 350). Add: - -```html -
-
-
-

Persistent notifications

-

Disable auto-dismiss for toast messages

-
- -
-
-``` - -In the script section, add: - -```ts -const persistentNotifications = ref(false) - -async function togglePersistentNotifications() { - persistentNotifications.value = !persistentNotifications.value - await settingsStore.updateSetting('persistent_notifications', persistentNotifications.value ? 'true' : 'false') - toastStore.setPersistentNotifications(persistentNotifications.value) -} -``` - -And in `onMounted`, after settings fetch: -```ts -persistentNotifications.value = settingsStore.settings.persistent_notifications === 'true' -toastStore.setPersistentNotifications(persistentNotifications.value) -``` - -**Step 3:** In App.vue, add after line 136 (after audio engine setup), before `recurringStore.fetchEntries()`: - -```ts -import { useToastStore } from './stores/toast' -const toastStore = useToastStore() -toastStore.setPersistentNotifications(settingsStore.settings.persistent_notifications === 'true') -``` - -**Step 4:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 5:** Commit: `git add src/views/Settings.vue src/App.vue && git commit -m "feat: persistent notifications toggle in settings"` - ---- - -### Task 4: Unified error handler utility - -**Files:** -- Create: `src/utils/errorHandler.ts` - -**Step 1:** Create the file: - -```ts -import { useToastStore } from '../stores/toast' - -export function handleInvokeError(error: unknown, context: string, retryFn?: () => Promise) { - const toastStore = useToastStore() - const message = error instanceof Error ? error.message : String(error) - - console.error(`${context}:`, message) - - const isTransient = /database is locked|connection|busy|timeout|network/i.test(message) - - if (isTransient && retryFn) { - toastStore.error(`${context}. Tap Retry to try again.`, { - onUndo: async () => { - try { - await retryFn() - toastStore.success('Operation completed successfully') - } catch (retryError) { - handleInvokeError(retryError, context) - } - }, - }) - } else { - toastStore.error(context) - } -} -``` - -**Step 2:** Verify: `npx vue-tsc --noEmit` - -**Step 3:** Commit: `git add src/utils/errorHandler.ts && git commit -m "feat: unified error handler with retry for transient errors"` - ---- - -### Task 5: Standardize error handling in entries store - -**Files:** -- Modify: `src/stores/entries.ts` - -**Step 1:** Read `src/stores/entries.ts` (119 lines). - -**Step 2:** Add import at line 3: -```ts -import { handleInvokeError } from '../utils/errorHandler' -``` - -**Step 3:** Replace each `console.error(...)` in catch blocks with `handleInvokeError()`: - -- `fetchEntriesPaginated` catch (line 34-35): Replace `console.error('Failed to fetch time entries:', error)` with `handleInvokeError(error, 'Failed to fetch time entries', () => fetchEntriesPaginated(startDate, endDate))` -- `fetchMore` catch (line 52-53): Replace with `handleInvokeError(error, 'Failed to load more entries', () => fetchMore(startDate, endDate))` -- `fetchEntries` catch (line 64-65): Replace with `handleInvokeError(error, 'Failed to fetch time entries', () => fetchEntries(startDate, endDate))` -- `createEntry` catch (line 77-78): Replace `console.error` with `handleInvokeError(error, 'Failed to create time entry')` and keep the `throw error` -- `updateEntry` catch (line 91-92): Replace with `handleInvokeError(error, 'Failed to update time entry')` and keep `throw error` -- `deleteEntry` catch (line 102-103): Replace with `handleInvokeError(error, 'Failed to delete time entry')` and keep `throw error` - -**Step 4:** Verify: `npx vue-tsc --noEmit` - -**Step 5:** Commit: `git add src/stores/entries.ts && git commit -m "feat: use unified error handler in entries store"` - ---- - -### Task 6: Standardize error handling in remaining stores - -**Files:** -- Modify: all stores that use `console.error` in catch blocks - -**Step 1:** Search for all stores with `console.error` patterns: -- `src/stores/timer.ts` -- `src/stores/settings.ts` -- `src/stores/invoices.ts` -- `src/stores/projects.ts` (if it exists) -- `src/stores/expenses.ts` (if it exists) -- `src/stores/tags.ts` (if it exists) -- `src/stores/recurring.ts` (if it exists) -- `src/stores/onboarding.ts` - -**Step 2:** For each store, add `import { handleInvokeError } from '../utils/errorHandler'` and replace `console.error(...)` in catch blocks with `handleInvokeError(error, 'Context message', retryFn?)`. Use `handleInvokeError` for user-facing operations only - keep `console.error` for internal/background operations like `persistState()` in timer.ts, `detectCompletions()` in onboarding.ts, and other operations where showing a toast would be disruptive. - -**Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 4:** Commit: `git add src/stores/ && git commit -m "feat: standardize error handling across all stores"` - ---- - -### Task 7: Onboarding detection resilience - -**Files:** -- Modify: `src/stores/onboarding.ts` (lines 83-98, `detectCompletions` function) -- Modify: `src/App.vue` (add periodic re-check) - -**Step 1:** Read `src/stores/onboarding.ts` lines 75-105. - -**Step 2:** Refactor `detectCompletions()` to wrap each invoke in its own try/catch: - -```ts -async function detectCompletions() { - try { - const clients = await invoke('get_clients') - if (clients.length > 0) markComplete('create_client') - } catch { /* individual failure - continue */ } - - try { - const projects = await invoke('get_projects') - if (projects.length > 0) markComplete('create_project') - } catch { /* individual failure - continue */ } - - try { - const entries = await invoke('get_time_entries', { startDate: null, endDate: null }) - if (entries.length > 0) markComplete('track_time') - } catch { /* individual failure - continue */ } - - try { - const invoices = await invoke('get_invoices') - if (invoices.length > 0) markComplete('create_invoice') - } catch { /* individual failure - continue */ } -} -``` - -**Step 3:** In `src/App.vue`, add after the recurring check interval (line 140), before calendar sync: - -```ts -// Periodic onboarding re-check -const onboardingInterval = setInterval(() => onboardingStore.detectCompletions(), 5 * 60000) -``` - -Note: No cleanup needed since App.vue lives for the entire app lifetime. - -**Step 4:** Verify: `npx vue-tsc --noEmit` - -**Step 5:** Commit: `git add src/stores/onboarding.ts src/App.vue && git commit -m "fix: independent try/catch per onboarding detection call"` - ---- - -### Task 8: Invoice items batch save - backend command - -**Files:** -- Modify: `src-tauri/src/commands.rs` (add `save_invoice_items_batch` after existing invoice item functions around line 560) -- Modify: `src-tauri/src/lib.rs` (register command) - -**Step 1:** Read `src-tauri/src/commands.rs` lines 519-570 to see existing invoice item functions. - -**Step 2:** Add the batch command after `delete_invoice_items`: - -```rust -#[tauri::command] -pub fn save_invoice_items_batch( - state: State, - invoice_id: i64, - items: Vec, -) -> Result, String> { - let conn = state.db.lock().map_err(|e| e.to_string())?; - conn.execute("BEGIN TRANSACTION", []).map_err(|e| e.to_string())?; - - // Delete existing items first - if let Err(e) = conn.execute("DELETE FROM invoice_items WHERE invoice_id = ?1", params![invoice_id]) { - let _ = conn.execute("ROLLBACK", []); - return Err(e.to_string()); - } - - let mut ids = Vec::new(); - for item in &items { - let description = item.get("description").and_then(|v| v.as_str()).unwrap_or(""); - let quantity = item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(0.0); - let unit_price = item.get("unit_price").and_then(|v| v.as_f64()).unwrap_or(0.0); - let amount = quantity * unit_price; - let time_entry_id = item.get("time_entry_id").and_then(|v| v.as_i64()); - - match conn.execute( - "INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount, time_entry_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![invoice_id, description, quantity, unit_price, amount, time_entry_id], - ) { - Ok(_) => ids.push(conn.last_insert_rowid()), - Err(e) => { - let _ = conn.execute("ROLLBACK", []); - return Err(format!("Failed to save item: {}", e)); - } - } - } - - conn.execute("COMMIT", []).map_err(|e| e.to_string())?; - Ok(ids) -} -``` - -**Step 3:** Register in `src-tauri/src/lib.rs` - add `commands::save_invoice_items_batch` in the `generate_handler!` macro near the other invoice commands (around line 68). - -**Step 4:** Verify: `cd src-tauri && cargo build` - -**Step 5:** Commit: `git add src-tauri/src/commands.rs src-tauri/src/lib.rs && git commit -m "feat: batch invoice items save with transaction"` - ---- - -### Task 9: Invoice items batch save - frontend - -**Files:** -- Modify: `src/stores/invoices.ts` (replace `saveInvoiceItems` loop with batch call) - -**Step 1:** Read `src/stores/invoices.ts` lines 110-130. - -**Step 2:** Replace the `saveInvoiceItems` function: - -```ts -async function saveInvoiceItems(invoiceId: number, items: Array<{ description: string; quantity: number; unit_price: number; time_entry_id?: number }>) { - try { - await invoke('save_invoice_items_batch', { - invoiceId, - items: items.map(item => ({ - description: item.description, - quantity: item.quantity, - unit_price: item.unit_price, - time_entry_id: item.time_entry_id || null, - })), - }) - } catch (error) { - handleInvokeError(error, 'Failed to save invoice items') - throw error - } -} -``` - -Add import: `import { handleInvokeError } from '../utils/errorHandler'` - -**Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 4:** Commit: `git add src/stores/invoices.ts && git commit -m "feat: use batch save for invoice items"` - ---- - -### Task 10: Smart timer safety net - dialog component - -**Files:** -- Create: `src/components/TimerSaveDialog.vue` - -**Step 1:** Create the component: - -```vue - - - -``` - -**Step 2:** Verify: `npx vue-tsc --noEmit` - -**Step 3:** Commit: `git add src/components/TimerSaveDialog.vue && git commit -m "feat: timer save dialog for no-project and long-timer scenarios"` - ---- - -### Task 11: Smart timer safety net - integration - -**Files:** -- Modify: `src/stores/timer.ts` (stop method, lines 382-447) -- Modify: `src/App.vue` (mount dialog) - -**Step 1:** Read `src/stores/timer.ts` lines 380-450. - -**Step 2:** In `timer.ts`, modify the `stop` function to emit an event instead of silently discarding when no project is selected: -- Add a new ref: `showSaveDialog = ref(false)` and `saveDialogMode = ref<'no-project' | 'long-timer'>('no-project')` -- Add `pendingStopDuration = ref(0)` to hold the duration for the dialog -- Before the `if (selectedProjectId.value)` block (line 408), add the safety net check: - -```ts -// Safety net: if no project and there's tracked time, show save dialog -if (!selectedProjectId.value && duration > 0) { - pendingStopDuration.value = duration - saveDialogMode.value = 'no-project' - showSaveDialog.value = true - // Don't reset state yet - dialog will handle it - return -} - -// Safety net: long timer confirmation (8+ hours) -const LONG_TIMER_THRESHOLD = 8 * 3600 -if (duration > LONG_TIMER_THRESHOLD && !options?.confirmed) { - pendingStopDuration.value = duration - saveDialogMode.value = 'long-timer' - showSaveDialog.value = true - return -} -``` - -Add to the stop function signature: `options?: { subtractIdleTime?: boolean; confirmed?: boolean }` - -Add new functions: - -```ts -async function handleSaveDialogSave(projectId: number, desc: string) { - showSaveDialog.value = false - const oldProjectId = selectedProjectId.value - const oldDescription = description.value - selectedProjectId.value = projectId - description.value = desc - await stop({ confirmed: true }) - selectedProjectId.value = oldProjectId - description.value = oldDescription -} - -function handleSaveDialogDiscard() { - showSaveDialog.value = false - // Reset without saving - stopDisplayInterval() - stopMonitorInterval() - timerState.value = 'STOPPED' - startTime.value = null - pausedAt.value = null - totalPausedMs.value = 0 - idleStartedAt.value = null - elapsedSeconds.value = 0 - showIdlePrompt.value = false - showAppPrompt.value = false - emitTimerSync() - announce('Timer stopped - entry discarded') - audioEngine.play('timer_stop') - const settingsStore = useSettingsStore() - settingsStore.updateSetting('timer_state_backup', '') -} - -function handleSaveDialogCancel() { - showSaveDialog.value = false - // Timer keeps running -} -``` - -Export the new refs and functions. - -**Step 3:** In `src/App.vue`, add the TimerSaveDialog mount after the RecurringPromptDialog: - -```html - -``` - -Add import: `import TimerSaveDialog from './components/TimerSaveDialog.vue'` -Add: `const timerStore = useTimerStore()` (may already be available - check line 100). - -**Step 4:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 5:** Commit: `git add src/stores/timer.ts src/App.vue && git commit -m "feat: smart timer safety net - save dialog on stop without project"` - ---- - -### Task 12: Phase 1 verification - -**Step 1:** Run: `npx vue-tsc --noEmit` -**Step 2:** Run: `npx vite build` -**Step 3:** Run: `cd src-tauri && cargo build` -**Step 4:** Commit if any fixes needed. - ---- - -## Phase 2: Power User Productivity (Features 6-10) - ---- - -### Task 13: Client cascade awareness - backend - -**Files:** -- Modify: `src-tauri/src/commands.rs` (add `get_client_dependents`, update `delete_client`) -- Modify: `src-tauri/src/lib.rs` (register command) - -**Step 1:** Read `src-tauri/src/commands.rs` lines 110-120 (existing `delete_client`). - -**Step 2:** Add `get_client_dependents` command: - -```rust -#[tauri::command] -pub fn get_client_dependents(state: State, client_id: i64) -> Result { - let conn = state.db.lock().map_err(|e| e.to_string())?; - let project_count: i64 = conn.query_row( - "SELECT COUNT(*) FROM projects WHERE client_id = ?1", params![client_id], |row| row.get(0) - ).map_err(|e| e.to_string())?; - let invoice_count: i64 = conn.query_row( - "SELECT COUNT(*) FROM invoices WHERE client_id = ?1", params![client_id], |row| row.get(0) - ).map_err(|e| e.to_string())?; - let expense_count: i64 = conn.query_row( - "SELECT COUNT(*) FROM expenses WHERE client_id = ?1", params![client_id], |row| row.get(0) - ).map_err(|e| e.to_string())?; - Ok(serde_json::json!({ - "projects": project_count, - "invoices": invoice_count, - "expenses": expense_count - })) -} -``` - -**Step 3:** Update `delete_client` to cascade inside a transaction: - -```rust -#[tauri::command] -pub fn delete_client(state: State, id: i64) -> Result<(), String> { - let conn = state.db.lock().map_err(|e| e.to_string())?; - conn.execute("BEGIN TRANSACTION", []).map_err(|e| e.to_string())?; - - // Get all project IDs for this client - let project_ids: Vec = { - let mut stmt = conn.prepare("SELECT id FROM projects WHERE client_id = ?1").map_err(|e| e.to_string())?; - let rows = stmt.query_map(params![id], |row| row.get(0)).map_err(|e| e.to_string())?; - rows.filter_map(|r| r.ok()).collect() - }; - - for pid in &project_ids { - // Delete dependent data for each project - let _ = conn.execute("DELETE FROM entry_tags WHERE entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![pid]); - let _ = conn.execute("DELETE FROM time_entries WHERE project_id = ?1", params![pid]); - let _ = conn.execute("DELETE FROM tasks WHERE project_id = ?1", params![pid]); - let _ = conn.execute("DELETE FROM tracked_apps WHERE project_id = ?1", params![pid]); - let _ = conn.execute("DELETE FROM favorites WHERE project_id = ?1", params![pid]); - let _ = conn.execute("DELETE FROM recurring_entries WHERE project_id = ?1", params![pid]); - let _ = conn.execute("DELETE FROM timeline_events WHERE project_id = ?1", params![pid]); - } - - let _ = conn.execute("DELETE FROM expenses WHERE client_id = ?1", params![id]); - // Delete invoice items for this client's invoices - let _ = conn.execute("DELETE FROM invoice_items WHERE invoice_id IN (SELECT id FROM invoices WHERE client_id = ?1)", params![id]); - let _ = conn.execute("DELETE FROM invoices WHERE client_id = ?1", params![id]); - let _ = conn.execute("DELETE FROM projects WHERE client_id = ?1", params![id]); - - match conn.execute("DELETE FROM clients WHERE id = ?1", params![id]) { - Ok(_) => { - conn.execute("COMMIT", []).map_err(|e| e.to_string())?; - Ok(()) - } - Err(e) => { - let _ = conn.execute("ROLLBACK", []); - Err(e.to_string()) - } - } -} -``` - -**Step 4:** Register `get_client_dependents` in `lib.rs` near other client commands. - -**Step 5:** Verify: `cd src-tauri && cargo build` - -**Step 6:** Commit: `git add src-tauri/src/commands.rs src-tauri/src/lib.rs && git commit -m "feat: client cascade delete with dependency counts"` - ---- - -### Task 14: Client cascade awareness - frontend - -**Files:** -- Modify: `src/views/Clients.vue` (wire AppCascadeDeleteDialog) - -**Step 1:** Read `src/views/Clients.vue` fully. Find the existing `confirmDelete` function and the delete dialog/flow. - -**Step 2:** Import `AppCascadeDeleteDialog` and wire it in. Follow the same pattern as Projects.vue. Add: -- A `deleteCandidate` ref for the client being deleted -- A `deleteImpacts` ref for the dependency counts -- Call `invoke('get_client_dependents', { clientId })` before showing the dialog -- Wire `AppCascadeDeleteDialog` with the impacts - -```ts -import AppCascadeDeleteDialog from '../components/AppCascadeDeleteDialog.vue' - -const deleteCandidate = ref(null) -const deleteImpacts = ref>([]) -const showCascadeDelete = ref(false) - -async function confirmDelete(client: any) { - try { - const deps = await invoke<{ projects: number; invoices: number; expenses: number }>('get_client_dependents', { clientId: client.id }) - const impacts: Array<{ label: string; count: number }> = [] - if (deps.projects > 0) impacts.push({ label: 'Projects', count: deps.projects }) - if (deps.invoices > 0) impacts.push({ label: 'Invoices', count: deps.invoices }) - if (deps.expenses > 0) impacts.push({ label: 'Expenses', count: deps.expenses }) - deleteCandidate.value = client - deleteImpacts.value = impacts - showCascadeDelete.value = true - } catch (error) { - handleInvokeError(error, 'Failed to check client dependencies') - } -} - -async function executeDelete() { - if (!deleteCandidate.value) return - try { - await invoke('delete_client', { id: deleteCandidate.value.id }) - // Remove from local state - // ... (depends on existing client store pattern) - toastStore.success('Client deleted') - } catch (error) { - handleInvokeError(error, 'Failed to delete client') - } finally { - showCascadeDelete.value = false - deleteCandidate.value = null - } -} -``` - -Add in template: -```html - -``` - -**Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 4:** Commit: `git add src/views/Clients.vue && git commit -m "feat: cascade delete dialog for clients with dependency counts"` - ---- - -### Task 15: Entry templates - backend - -**Files:** -- Modify: `src-tauri/src/database.rs` (add `entry_templates` table) -- Modify: `src-tauri/src/commands.rs` (add CRUD commands) -- Modify: `src-tauri/src/lib.rs` (register commands) - -**Step 1:** Read `src-tauri/src/database.rs` to find where to add the migration. - -**Step 2:** Add table creation after the last CREATE TABLE (before settings seed): - -```rust -conn.execute( - "CREATE TABLE IF NOT EXISTS entry_templates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - project_id INTEGER NOT NULL REFERENCES projects(id), - task_id INTEGER REFERENCES tasks(id), - description TEXT, - duration INTEGER NOT NULL DEFAULT 0, - billable INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - )", - [], -)?; -``` - -**Step 3:** Add CRUD commands in `commands.rs`: - -```rust -#[tauri::command] -pub fn get_entry_templates(state: State) -> Result, String> { - let conn = state.db.lock().map_err(|e| e.to_string())?; - let mut stmt = conn.prepare("SELECT id, name, project_id, task_id, description, duration, billable, created_at FROM entry_templates ORDER BY name").map_err(|e| e.to_string())?; - let rows = stmt.query_map([], |row| { - Ok(serde_json::json!({ - "id": row.get::<_, i64>(0)?, - "name": row.get::<_, String>(1)?, - "project_id": row.get::<_, i64>(2)?, - "task_id": row.get::<_, Option>(3)?, - "description": row.get::<_, Option>(4)?, - "duration": row.get::<_, i64>(5)?, - "billable": row.get::<_, i64>(6)?, - "created_at": row.get::<_, String>(7)?, - })) - }).map_err(|e| e.to_string())?; - Ok(rows.filter_map(|r| r.ok()).collect()) -} - -#[tauri::command] -pub fn create_entry_template(state: State, template: serde_json::Value) -> Result { - let conn = state.db.lock().map_err(|e| e.to_string())?; - 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( - "INSERT INTO entry_templates (name, project_id, task_id, description, duration, billable) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![name, project_id, task_id, description, duration, billable], - ).map_err(|e| e.to_string())?; - Ok(conn.last_insert_rowid()) -} - -#[tauri::command] -pub fn delete_entry_template(state: State, id: i64) -> Result<(), String> { - let conn = state.db.lock().map_err(|e| e.to_string())?; - conn.execute("DELETE FROM entry_templates WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; - Ok(()) -} -``` - -**Step 4:** Register all three commands in `lib.rs`. - -**Step 5:** Verify: `cd src-tauri && cargo build` - -**Step 6:** Commit: `git add src-tauri/ && git commit -m "feat: entry templates CRUD backend"` - ---- - -### Task 16: Entry templates - store - -**Files:** -- Create: `src/stores/entryTemplates.ts` - -**Step 1:** Create the store: - -```ts -import { defineStore } from 'pinia' -import { ref } from 'vue' -import { invoke } from '@tauri-apps/api/core' -import { handleInvokeError } from '../utils/errorHandler' - -export interface EntryTemplate { - id?: number - name: string - project_id: number - task_id?: number - description?: string - duration: number - billable: number - created_at?: string -} - -export const useEntryTemplatesStore = defineStore('entryTemplates', () => { - const templates = ref([]) - const loading = ref(false) - - async function fetchTemplates() { - loading.value = true - try { - templates.value = await invoke('get_entry_templates') - } catch (error) { - handleInvokeError(error, 'Failed to fetch entry templates') - } finally { - loading.value = false - } - } - - async function createTemplate(template: Omit): Promise { - try { - const id = await invoke('create_entry_template', { template }) - templates.value.push({ ...template, id: Number(id) }) - return Number(id) - } catch (error) { - handleInvokeError(error, 'Failed to create template') - return null - } - } - - async function deleteTemplate(id: number) { - try { - await invoke('delete_entry_template', { id }) - templates.value = templates.value.filter(t => t.id !== id) - } catch (error) { - handleInvokeError(error, 'Failed to delete template') - } - } - - return { templates, loading, fetchTemplates, createTemplate, deleteTemplate } -}) -``` - -**Step 2:** Verify: `npx vue-tsc --noEmit` - -**Step 3:** Commit: `git add src/stores/entryTemplates.ts && git commit -m "feat: entry templates pinia store"` - ---- - -### Task 17: Entry templates - picker dialog and Entries.vue integration - -**Files:** -- Create: `src/components/EntryTemplatePicker.vue` -- Modify: `src/views/Entries.vue` (add "From Template" button, "Save as Template" in edit dialog, duplicate button per row) - -**Step 1:** Create the picker dialog. This is a listbox dialog showing all saved templates. Clicking one emits the template data. Uses focus trap, keyboard navigation (ArrowUp/Down, Enter to select), and Escape to close. - -**Step 2:** In Entries.vue, add: -- A "From Template" button in the filters bar (near the "Copy Yesterday" / "Copy Last Week" buttons) -- When clicked, opens EntryTemplatePicker dialog -- On template selection, pre-fills the edit dialog in create mode -- A "Save as Template" button in the edit dialog (visible when editing an existing entry) -- The existing duplicate button on entry rows (already present per lines 181-185) should have its `aria-label` enhanced to include project name and date - -**Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 4:** Commit: `git add src/components/EntryTemplatePicker.vue src/views/Entries.vue && git commit -m "feat: entry template picker and save-as-template in entries view"` - ---- - -### Task 18: Entry templates - Settings management - -**Files:** -- Modify: `src/views/Settings.vue` (Timer tab, add template management section) - -**Step 1:** Read Settings.vue Timer tab. Find where to insert (after Recurring Entries section, before the end of Timer tab panel, around line 775). - -**Step 2:** Add a "Saved Templates" section: - -```html - -
- - -

Entry Templates

- -
-
-
-

{{ tpl.name }}

-

- {{ getProjectName(tpl.project_id) }} - {{ formatDuration(tpl.duration) }} -

-
- -
-
-

No saved templates

-``` - -Add script setup code for fetching and deleting templates via `useEntryTemplatesStore`. - -**Step 3:** Verify: `npx vue-tsc --noEmit` - -**Step 4:** Commit: `git add src/views/Settings.vue && git commit -m "feat: entry template management in settings"` - ---- - -### Task 19: Timesheet smart row persistence - backend - -**Files:** -- Modify: `src-tauri/src/database.rs` (add `timesheet_rows` table) -- Modify: `src-tauri/src/commands.rs` (add 3 commands) -- Modify: `src-tauri/src/lib.rs` (register) - -**Step 1:** Add table: - -```rust -conn.execute( - "CREATE TABLE IF NOT EXISTS timesheet_rows ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - week_start TEXT NOT NULL, - project_id INTEGER NOT NULL REFERENCES projects(id), - task_id INTEGER REFERENCES tasks(id), - sort_order INTEGER NOT NULL DEFAULT 0 - )", - [], -)?; -``` - -**Step 2:** Add commands: - -```rust -#[tauri::command] -pub fn get_timesheet_rows(state: State, week_start: String) -> Result, String> { ... } - -#[tauri::command] -pub fn save_timesheet_rows(state: State, week_start: String, rows: Vec) -> Result<(), String> { ... } - -#[tauri::command] -pub fn get_previous_week_structure(state: State, current_week_start: String) -> Result, String> { - // Calculate previous Monday (current - 7 days) - // Return rows from that week -} -``` - -**Step 3:** Register in `lib.rs`. - -**Step 4:** Verify: `cd src-tauri && cargo build` - -**Step 5:** Commit: `git add src-tauri/ && git commit -m "feat: timesheet row persistence backend"` - ---- - -### Task 20: Timesheet smart row persistence - frontend - -**Files:** -- Modify: `src/views/TimesheetView.vue` - -**Step 1:** Read `TimesheetView.vue` fully. - -**Step 2:** Add: -- On week navigation, call `get_timesheet_rows` for the new week -- If no rows exist, auto-populate from previous week structure via `get_previous_week_structure` -- When user adds/removes rows, call `save_timesheet_rows` to persist -- Add "Copy Last Week" button that copies both structure and values -- Confirmation dialog before overwriting existing data - -**Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 4:** Commit: `git add src/views/TimesheetView.vue && git commit -m "feat: timesheet row persistence and copy last week"` - ---- - -### Task 21: Global quick entry - dialog component - -**Files:** -- Create: `src/components/QuickEntryDialog.vue` - -**Step 1:** Create the dialog with: -- Project picker (AppSelect) -- Task picker filtered by project -- Description input -- Date picker (defaults to today) -- Duration input (supports H:MM format) -- Tag picker (AppTagInput) -- Save and Cancel buttons -- Focus trap, `role="dialog"`, `aria-modal="true"`, Escape closes -- Pre-fill with last-used project from settings - -This component follows the same dialog pattern as the edit entry dialog in Entries.vue. - -**Step 2:** Verify: `npx vue-tsc --noEmit` - -**Step 3:** Commit: `git add src/components/QuickEntryDialog.vue && git commit -m "feat: global quick entry dialog component"` - ---- - -### Task 22: Global quick entry - shortcut integration - -**Files:** -- Modify: `src/App.vue` (mount dialog, register shortcut) -- Modify: `src/views/Settings.vue` (add shortcut recorder for quick entry) - -**Step 1:** In App.vue: -- Import and mount QuickEntryDialog -- Add a `showQuickEntry` ref -- In `registerShortcuts()`, add a third shortcut for the configurable quick entry key -- When shortcut fires, set `showQuickEntry = true` - -**Step 2:** In Settings.vue Timer tab, add a shortcut recorder for "Quick Entry" after the existing keyboard shortcuts section (after line 544). - -**Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 4:** Commit: `git add src/App.vue src/views/Settings.vue && git commit -m "feat: global shortcut for quick entry dialog"` - ---- - -### Task 23: Expenses receipt management - lightbox component - -**Files:** -- Create: `src/components/ReceiptLightbox.vue` - -**Step 1:** Create a lightbox component for full-size receipt viewing: -- `role="dialog"`, `aria-modal="true"`, `aria-label="Receipt image for [description]"` -- Focus trap with Escape to close -- Image display with zoom controls (+/- buttons, keyboard-accessible) -- `alt` text: "Receipt for [category] expense on [date], [amount]" -- Close button with keyboard focus - -**Step 2:** Verify: `npx vue-tsc --noEmit` - -**Step 3:** Commit: `git add src/components/ReceiptLightbox.vue && git commit -m "feat: receipt lightbox component"` - ---- - -### Task 24: Expenses receipt management - Entries.vue integration - -**Files:** -- Modify: `src/views/Entries.vue` (expense tab) - -**Step 1:** Read Entries.vue expense table section (lines 332-416). - -**Step 2:** Add to each expense row: -- Receipt thumbnail (small image) if `receipt_path` exists -- "No receipt" indicator (icon + text) if no `receipt_path` -- Click thumbnail to open ReceiptLightbox -- In the expense edit dialog, add a file picker button using `@tauri-apps/plugin-dialog` open() -- Add a drop zone div with `@dragover`, `@drop` handlers and keyboard activation (Enter/Space opens file picker) -- Drop zone: `role="button"`, `aria-label="Drop receipt file here or press Enter to browse"` - -**Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 4:** Commit: `git add src/views/Entries.vue && git commit -m "feat: receipt thumbnails, lightbox, and file picker for expenses"` - ---- - -### Task 25: Phase 2 verification - -**Step 1:** Run: `npx vue-tsc --noEmit` -**Step 2:** Run: `npx vite build` -**Step 3:** Run: `cd src-tauri && cargo build` -**Step 4:** Commit if any fixes needed. - ---- - -## Phase 3: Data and Insights (Features 11-15) - ---- - -### Task 26: Dashboard weekly comparison - -**Files:** -- Modify: `src/views/Dashboard.vue` - -**Step 1:** Read Dashboard.vue fully (especially the stats dl section lines 45-62 and onMounted). - -**Step 2:** Add: -- `lastWeekStats` ref fetched via `invoke('get_reports', { startDate: lastWeekStart, endDate: lastWeekEnd })` -- For each stat card (Today, This Week, This Month), add a comparison indicator: - -```html -
- Compared to last week: -
-``` - -- Add sparkline section below weekly chart: 4 mini bar groups (last 4 weeks), each showing 7 bars. Use `role="img"` wrapper with `aria-label` summarizing all 4 weeks' totals. - -**Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 4:** Commit: `git add src/views/Dashboard.vue && git commit -m "feat: weekly comparison indicators and sparklines on dashboard"` - ---- - -### Task 27: Project health dashboard cards - -**Files:** -- Modify: `src/views/Projects.vue` - -**Step 1:** Read Projects.vue fully. - -**Step 2:** Add health badge computation using existing `budgetStatus`: -- "On Track": checkmark icon + "On Track" text (green icon) -- "At Risk": warning triangle icon + "At Risk" text (yellow icon) -- "Over Budget": x-circle icon + "Over Budget" text (red icon) -- Logic: percent > 100 = over budget, pace === "behind" && percent > 75 = at risk, else on track -- Each uses icon + text label (never color alone) -- Badge: `role="status"`, icons `aria-hidden="true"` - -Add "Attention Needed" section at top of project grid: -```html -
-

Attention Needed

- -
-``` - -**Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 4:** Commit: `git add src/views/Projects.vue && git commit -m "feat: project health badges and attention section"` - ---- - -### Task 28: Reports time-of-day heatmap - Patterns tab - -**Files:** -- Modify: `src/views/Reports.vue` - -**Step 1:** Read Reports.vue tab section (lines 52-98). - -**Step 2:** Add a "Patterns" tab button after the Expenses tab: - -```html - -``` - -**Step 3:** Add the Patterns tab panel: -- 7x24 grid (days x hours): `role="grid"` with `role="row"` and `role="gridcell"` -- Each cell shows numeric value (e.g., "2.5h") and uses background color intensity -- Cells with 0 hours are empty -- Arrow key navigation between cells -- Each cell: `aria-label="Monday 9 AM: 2.5 hours"` -- "Data Table" toggle button that switches to a standard `` with proper `
` and `` -- Summary stats: "Most productive: Monday 9-10 AM", "Quietest: Sunday" - -Data computation: -```ts -function computePatterns() { - const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0)) - for (const entry of entriesStore.entries) { - const d = new Date(entry.start_time) - const day = d.getDay() === 0 ? 6 : d.getDay() - 1 // Mon=0, Sun=6 - const hour = d.getHours() - grid[day][hour] += entry.duration / 3600 - } - heatmapData.value = grid - patternsLoaded = true -} -``` - -**Step 4:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 5:** Commit: `git add src/views/Reports.vue && git commit -m "feat: time-of-day heatmap in reports patterns tab"` - ---- - -### Task 29: Rounding visibility - Entries.vue - -**Files:** -- Modify: `src/views/Entries.vue` - -**Step 1:** Read the duration column in the entries table (around line 161-169). - -**Step 2:** Import `roundDuration` from `../utils/rounding` and settings store. For each entry, compute if rounding changes the value: - -```ts -import { roundDuration } from '../utils/rounding' - -function getRoundedDuration(seconds: number): number | null { - if (settingsStore.settings.rounding_enabled !== 'true') return null - const increment = parseInt(settingsStore.settings.rounding_increment) || 0 - const method = (settingsStore.settings.rounding_method || 'nearest') as 'nearest' | 'up' | 'down' - if (increment <= 0) return null - const rounded = roundDuration(seconds, increment, method) - return rounded !== seconds ? rounded : null -} -``` - -In the duration ``, after the duration display, add: -```html - - -``` - -Add a tooltip mechanism using `title` attribute or a custom tooltip with `role="tooltip"` and `aria-describedby` for the actual vs rounded values. - -**Step 3:** Verify: `npx vue-tsc --noEmit` - -**Step 4:** Commit: `git add src/views/Entries.vue && git commit -m "feat: rounding visibility indicators on entry rows"` - ---- - -### Task 30: Rounding visibility - Invoices and Reports - -**Files:** -- Modify: `src/views/Invoices.vue` (show actual vs rounded on line items) -- Modify: `src/views/Reports.vue` (add rounding impact summary in Hours tab) - -**Step 1:** In Invoices.vue, where invoice line items are displayed, show both actual and rounded hours when rounding is active. Add a small "+Xm" or "-Xm" text label. - -**Step 2:** In Reports.vue Hours tab, after the billable split line (around line 124), add a rounding impact summary: - -```html -
- Rounding {{ roundingImpact > 0 ? 'added' : 'subtracted' }}: - {{ formatHours(Math.abs(roundingImpact)) }} - across {{ roundedEntryCount }} entries - -
-``` - -**Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 4:** Commit: `git add src/views/Invoices.vue src/views/Reports.vue && git commit -m "feat: rounding visibility in invoices and reports"` - ---- - -### Task 31: Export completeness - backend - -**Files:** -- Modify: `src-tauri/src/commands.rs` (expand `export_data`, update `import_json_data`, add `auto_backup`) -- Modify: `src-tauri/src/lib.rs` (register) - -**Step 1:** Read `export_data` (lines 586-669) and `import_json_data` (lines 1501-1609). - -**Step 2:** Expand `export_data` to include ALL tables: -- Add: tasks, tags, entry_tags, tracked_apps, favorites, recurring_entries, expenses, timeline_events, calendar_sources, calendar_events, timesheet_locks, invoice_items, settings, entry_templates, timesheet_rows - -**Step 3:** Update `import_json_data` to handle expanded format. Import new tables if present in the JSON, skip if not (backward compatible). - -**Step 4:** Add `auto_backup` command: - -```rust -#[tauri::command] -pub fn auto_backup(state: State, backup_dir: String) -> Result { - let data = export_data(state)?; - let today = chrono::Local::now().format("%Y-%m-%d").to_string(); - let filename = format!("zeroclock-backup-{}.json", today); - let path = std::path::Path::new(&backup_dir).join(&filename); - let json = serde_json::to_string_pretty(&data).map_err(|e| e.to_string())?; - std::fs::write(&path, json).map_err(|e| e.to_string())?; - Ok(path.to_string_lossy().to_string()) -} -``` - -Note: You may need to add `chrono` to Cargo.toml dependencies if not already present, or use a simpler date formatting approach. - -**Step 5:** Register `auto_backup` in `lib.rs`. - -**Step 6:** Verify: `cd src-tauri && cargo build` - -**Step 7:** Commit: `git add src-tauri/ && git commit -m "feat: comprehensive export with all tables and auto-backup command"` - ---- - -### Task 32: Export completeness - Settings UI - -**Files:** -- Modify: `src/views/Settings.vue` (Data tab, lines 1114-1200) -- Modify: `src/App.vue` (hook into window close event) - -**Step 1:** In Settings.vue Data tab, add after the Export section (line 1131): - -```html - -
-
-

Last exported

-

{{ lastExportedFormatted }}

-
-
- - -
- - -
-
-

Auto-backup on close

-

Save a backup when the app closes

-
- -
- - -
-
-

Backup directory

-

{{ backupPath || 'Not set' }}

-
- -
-``` - -Script additions: -```ts -const autoBackupEnabled = ref(false) -const backupPath = ref('') -const lastExported = ref('') - -// On mount: -autoBackupEnabled.value = settingsStore.settings.auto_backup === 'true' -backupPath.value = settingsStore.settings.backup_path || '' -lastExported.value = settingsStore.settings.last_exported || '' - -async function toggleAutoBackup() { - autoBackupEnabled.value = !autoBackupEnabled.value - await settingsStore.updateSetting('auto_backup', autoBackupEnabled.value ? 'true' : 'false') -} - -async function chooseBackupPath() { - const { open } = await import('@tauri-apps/plugin-dialog') - const selected = await open({ directory: true, title: 'Choose backup directory' }) - if (selected && typeof selected === 'string') { - backupPath.value = selected - await settingsStore.updateSetting('backup_path', selected) - } -} -``` - -Update `exportData` to also set `last_exported`: -```ts -async function exportData() { - // ... existing export logic ... - const now = new Date().toISOString() - await settingsStore.updateSetting('last_exported', now) - lastExported.value = now -} -``` - -**Step 2:** In App.vue, hook into window close event for auto-backup. Add in `onMounted`: - -```ts -const { getCurrentWindow } = await import('@tauri-apps/api/window') -const win = getCurrentWindow() -win.onCloseRequested(async () => { - if (settingsStore.settings.auto_backup === 'true' && settingsStore.settings.backup_path) { - try { - await invoke('auto_backup', { backupDir: settingsStore.settings.backup_path }) - } catch (e) { - console.error('Auto-backup failed:', e) - } - } -}) -``` - -**Step 3:** Verify: `npx vue-tsc --noEmit && npx vite build` - -**Step 4:** Commit: `git add src/views/Settings.vue src/App.vue && git commit -m "feat: auto-backup UI and window close hook"` - ---- - -### Task 33: Phase 3 verification - -**Step 1:** Run: `npx vue-tsc --noEmit` -**Step 2:** Run: `npx vite build` -**Step 3:** Run: `cd src-tauri && cargo build` -**Step 4:** Commit if any fixes needed. - ---- - -### Task 34: Final verification - -**Step 1:** Run all three verification commands: -``` -npx vue-tsc --noEmit && npx vite build && cd src-tauri && cargo build -``` - -**Step 2:** Ensure no regressions. Fix any type errors or build failures. - -**Step 3:** Final commit if any cleanup needed. 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 @@