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

- Custom tooltip directive (WCAG AAA) on every button in the app
- Two-column timer layout with sticky hero and recent entries sidebar
- Timer font selector with 16 monospace Google Fonts and live preview
- UI font selector with 15+ Google Fonts
- Close-to-tray and minimize-to-tray settings
- New app icons (no-glow variants), platform icon set
- Mini timer pop-out window
- Favorites strip with drag-reorder and inline actions
- Comprehensive README with feature documentation
- Remove tracked files that belong in gitignore
This commit is contained in:
Your Name
2026-02-21 01:15:57 +02:00
parent 2608f447de
commit 514090eed4
144 changed files with 13351 additions and 3456 deletions

View File

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

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules
dist
docs
trash
# Rust/Tauri build artifacts

264
README.md Normal file
View File

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

View File

@@ -1,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 `<table>` with `<th scope="col">` and `<th scope="row">`
- 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

File diff suppressed because it is too large Load Diff

15
mini-timer.html Normal file
View File

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

1515
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

44
src-tauri/Cargo.lock generated
View File

@@ -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"

View File

@@ -1,12 +1,12 @@
[package]
name = "local-time-tracker"
name = "zeroclock"
version = "1.0.0"
description = "A local time tracking app with invoicing"
authors = ["you"]
edition = "2021"
[lib]
name = "local_time_tracker_lib"
name = "zeroclock_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 B

After

Width:  |  Height:  |  Size: 2.7 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

View File

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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

View File

@@ -15,6 +15,7 @@ pub struct Client {
pub tax_id: Option<String>,
pub payment_terms: Option<String>,
pub notes: Option<String>,
pub currency: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -28,6 +29,8 @@ pub struct Project {
pub budget_hours: Option<f64>,
pub budget_amount: Option<f64>,
pub rounding_override: Option<i32>,
pub notes: Option<String>,
pub currency: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -36,6 +39,7 @@ pub struct Task {
pub project_id: i64,
pub name: String,
pub estimated_hours: Option<f64>,
pub hourly_rate: Option<f64>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -72,7 +76,7 @@ pub struct Invoice {
pub fn get_clients(state: State<AppState>) -> Result<Vec<Client>, 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<AppState>) -> Result<Vec<Client>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
@@ -94,8 +99,8 @@ pub fn get_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
pub fn create_client(state: State<AppState>, client: Client) -> Result<i64, String> {
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<AppState>, client: Client) -> Result<i64, Stri
pub fn update_client(state: State<AppState>, 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<AppState>, 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<AppState>, id: i64) -> Result<(), String> {
pub fn get_projects(state: State<AppState>) -> Result<Vec<Project>, 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<AppState>) -> Result<Vec<Project>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
@@ -199,8 +211,8 @@ pub fn get_projects(state: State<AppState>) -> Result<Vec<Project>, String> {
pub fn create_project(state: State<AppState>, project: Project) -> Result<i64, String> {
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<AppState>, project: Project) -> Result<i64, S
pub fn update_project(state: State<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, id: i64) -> Result<(), String> {
#[tauri::command]
pub fn get_tasks(state: State<AppState>, project_id: i64) -> Result<Vec<Task>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
@@ -301,8 +317,8 @@ pub fn get_tasks(state: State<AppState>, project_id: i64) -> Result<Vec<Task>, S
pub fn create_task(state: State<AppState>, task: Task) -> Result<i64, String> {
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<AppState>, task: Task) -> Result<i64, String> {
#[tauri::command]
pub fn delete_task(state: State<AppState>, 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<AppState>, id: i64) -> Result<(), String> {
pub fn update_task(state: State<AppState>, 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<AppState>, 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<AppState>, invoice: Invoice) -> Result<(), St
#[tauri::command]
pub fn delete_invoice(state: State<AppState>, 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<AppState>) -> Result<serde_json::Value, String>
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<serde_json::Value> = stmt.query_map([], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
@@ -692,14 +714,15 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
"phone": row.get::<_, Option<String>>(5)?,
"tax_id": row.get::<_, Option<String>>(6)?,
"payment_terms": row.get::<_, Option<String>>(7)?,
"notes": row.get::<_, Option<String>>(8)?
"notes": row.get::<_, Option<String>>(8)?,
"currency": row.get::<_, Option<String>>(9)?
}))
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().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<serde_json::Value> = stmt.query_map([], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
@@ -710,20 +733,23 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
"archived": row.get::<_, i32>(5)? != 0,
"budget_hours": row.get::<_, Option<f64>>(6)?,
"budget_amount": row.get::<_, Option<f64>>(7)?,
"rounding_override": row.get::<_, Option<i32>>(8)?
"rounding_override": row.get::<_, Option<i32>>(8)?,
"notes": row.get::<_, Option<String>>(9)?,
"currency": row.get::<_, Option<String>>(10)?
}))
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().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<serde_json::Value> = 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<f64>>(3)?
"estimated_hours": row.get::<_, Option<f64>>(3)?,
"hourly_rate": row.get::<_, Option<f64>>(4)?
}))
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
rows
@@ -1004,13 +1030,26 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
pub fn clear_all_data(state: State<AppState>) -> 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<AppState>, today: String) -> Result<serde_
}))
}
// Profitability report command
// Profitability report command - includes expenses for net profit
#[tauri::command]
pub fn get_profitability_report(state: State<AppState>, start_date: String, end_date: String) -> Result<Vec<serde_json::Value>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
@@ -1687,7 +1726,8 @@ pub fn get_profitability_report(state: State<AppState>, 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<serde_json::Value> = 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<AppState>, start_date: String, end_
let budget_amount: Option<f64> = 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<String>>(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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
// Add expense totals per project for the date range
let mut result: Vec<serde_json::Value> = 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<AppState>, backup_dir: String) -> Result<String,
Ok(path.to_string_lossy().to_string())
}
#[tauri::command]
pub fn list_backup_files(backup_dir: String) -> Result<Vec<serde_json::Value>, String> {
let dir = std::path::Path::new(&backup_dir);
if !dir.exists() {
return Ok(Vec::new());
}
let mut files: Vec<serde_json::Value> = 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<AppState>) -> Result<Vec<String>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
// Check for overlapping time entries
#[tauri::command]
pub fn check_entry_overlap(
state: State<AppState>,
start_time: String,
end_time: String,
exclude_id: Option<i64>,
) -> Result<Vec<serde_json::Value>, 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<String>>(1)?,
"start_time": row.get::<_, String>(2)?,
"end_time": row.get::<_, Option<String>>(3)?,
"project_name": row.get::<_, String>(4)?
}))
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().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<String>>(1)?,
"start_time": row.get::<_, String>(2)?,
"end_time": row.get::<_, Option<String>>(3)?,
"project_name": row.get::<_, String>(4)?
}))
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().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<AppState>, project_id: i64) -> Result<Vec<serde_json::Value>, 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<f64> = 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<f64>>(3)?,
"actual_seconds": actual_seconds,
"actual_hours": actual_hours,
"variance": variance,
"progress": progress
}))
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
// Invoice payment struct and commands
#[derive(Debug, Serialize, Deserialize)]
pub struct InvoicePayment {
pub id: Option<i64>,
pub invoice_id: i64,
pub amount: f64,
pub date: String,
pub method: Option<String>,
pub notes: Option<String>,
}
#[tauri::command]
pub fn get_invoice_payments(state: State<AppState>, invoice_id: i64) -> Result<Vec<InvoicePayment>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn add_invoice_payment(state: State<AppState>, payment: InvoicePayment) -> Result<i64, String> {
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<AppState>, 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<i64>,
pub client_id: i64,
pub template_id: Option<String>,
pub line_items_json: String,
pub tax_rate: f64,
pub discount: f64,
pub notes: Option<String>,
pub recurrence_rule: String,
pub next_due_date: String,
pub enabled: Option<i64>,
}
#[tauri::command]
pub fn get_recurring_invoices(state: State<AppState>) -> Result<Vec<RecurringInvoice>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn create_recurring_invoice(state: State<AppState>, invoice: RecurringInvoice) -> Result<i64, String> {
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<AppState>, 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<AppState>, 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<AppState>, today: String) -> Result<Vec<i64>, 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>, String, f64, f64, Option<String>, 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::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
let mut created_ids: Vec<i64> = 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::Value> = 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<String>,
end_time: Option<String>,
location: Option<String>,
description: Option<String>,
duration: i64,
}
fn parse_ics_datetime(dt: &str) -> Option<String> {
@@ -2323,7 +2767,67 @@ fn parse_ics_datetime(dt: &str) -> Option<String> {
}
}
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<i64> {
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<i64> {
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<ParsedCalendarEvent> {
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<ParsedCalendarEvent> {
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<ParsedCalendarEvent> {
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<ParsedCalendarEvent> {
}
} 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<AppState>, query: String, limit: Option<i64>) -> Result<Vec<serde_json::Value>, 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<String>>(2)?,
"start_time": row.get::<_, String>(3)?,
"duration": row.get::<_, i64>(4)?,
"project_name": row.get::<_, Option<String>>(5)?,
"project_color": row.get::<_, Option<String>>(6)?,
}))
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn bulk_delete_entries(state: State<AppState>, ids: Vec<i64>) -> Result<(), String> {
if ids.is_empty() { return Ok(()); }
@@ -2718,6 +3261,7 @@ pub fn bulk_delete_entries(state: State<AppState>, ids: Vec<i64>) -> 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<AppState>, id: i64) -> Result<(), Stri
Ok(())
}
#[tauri::command]
pub fn update_entry_template(state: State<AppState>, 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<AppState>, week_start: String) -> Result<Vec<serde_json::Value>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
@@ -3080,3 +3641,10 @@ fn get_default_templates() -> Vec<InvoiceTemplate> {
},
]
}
#[tauri::command]
pub fn seed_sample_data(state: State<AppState>) -> Result<String, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
crate::seed::seed(&conn)?;
Ok("Sample data loaded".to_string())
}

View File

@@ -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')",

View File

@@ -6,6 +6,7 @@ use tauri::Manager;
mod database;
mod commands;
mod os_detection;
mod seed;
pub struct AppState {
pub db: Mutex<Connection>,
@@ -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| {

View File

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

View File

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

672
src-tauri/src/seed.rs Normal file
View File

@@ -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(())
}

View File

@@ -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
}

View File

@@ -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<any[]>('list_backup_files', { backupDir: s.backup_path })
if (files.length > retention) {
for (const old of files.slice(retention)) {
await invoke('delete_backup_file', { path: old.path })
}
}
toastStore.success('Auto-backup completed')
} catch (e) {
console.error('Scheduled backup failed:', e)
}
}
function applyMotion() {
const setting = settingsStore.settings.reduce_motion || 'system'
const el = document.documentElement
@@ -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<any[]>('get_time_entries', { startDate: todayStr, endDate: todayStr })
const totalSeconds = entries.reduce((sum: number, e: any) => sum + (e.duration || 0), 0)
const totalHours = totalSeconds / 3600
const goalHours = parseFloat(settingsStore.settings.daily_goal_hours) || 8
if (totalHours < goalHours) {
const remaining = (goalHours - totalHours).toFixed(1)
const toastStore = useToastStore()
toastStore.info(`End of day: ${totalHours.toFixed(1)}h logged today, ${remaining}h remaining to reach your ${goalHours}h goal`)
}
} catch {
// ignore
}
}
}
// Weekly summary (Monday check)
if (settingsStore.settings.weekly_summary_enabled === 'true' && now.getDay() === 1) {
const weekId = todayStr
if (reminderState.weeklySummaryShownWeek !== weekId && now.getHours() >= 9) {
reminderState.weeklySummaryShownWeek = weekId
try {
const lastMonday = new Date(now)
lastMonday.setDate(now.getDate() - 7)
const lastSunday = new Date(now)
lastSunday.setDate(now.getDate() - 1)
const entries = await invoke<any[]>('get_time_entries', {
startDate: lastMonday.toISOString().split('T')[0],
endDate: lastSunday.toISOString().split('T')[0],
})
const totalSeconds = entries.reduce((sum: number, e: any) => sum + (e.duration || 0), 0)
const totalHours = totalSeconds / 3600
const goalHours = parseFloat(settingsStore.settings.weekly_goal_hours) || 40
const toastStore = useToastStore()
toastStore.info(`Weekly summary: ${totalHours.toFixed(1)}h logged last week (goal: ${goalHours}h)`)
} catch {
// ignore
}
}
}
}
checkReminders()
setInterval(checkReminders, 60000)
registerShortcuts()
// 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) => {
<div id="route-announcer" class="sr-only" aria-live="polite" aria-atomic="true"></div>
<div id="announcer" class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div>
<TourOverlay />
<KeyboardShortcutsDialog :show="showShortcuts" @close="showShortcuts = false" />
<GlobalSearchDialog :show="showSearch" @close="showSearch = false" />
</template>

View File

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

View File

@@ -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<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(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()
}
}
</script>
<template>
@@ -305,6 +339,9 @@ onBeforeUnmount(() => {
ref="triggerRef"
type="button"
@click="toggle"
aria-label="Color picker"
:aria-expanded="isOpen"
aria-haspopup="dialog"
class="w-full flex items-center gap-2.5 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
:style="
isOpen
@@ -317,20 +354,26 @@ onBeforeUnmount(() => {
"
>
<span
role="img"
:aria-label="'Current color: ' + (modelValue?.toUpperCase() || '#000000')"
class="w-5 h-5 rounded-md border border-border-subtle shrink-0"
:style="{ backgroundColor: modelValue }"
/>
<span class="text-text-primary font-mono text-[0.75rem] tracking-wide">{{ modelValue?.toUpperCase() || '#000000' }}</span>
<Pipette class="w-4 h-4 text-text-secondary shrink-0 ml-auto" :stroke-width="2" />
<Pipette class="w-4 h-4 text-text-secondary shrink-0 ml-auto" :stroke-width="2" aria-hidden="true" />
</button>
<!-- Color picker popover -->
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="dialog"
aria-modal="true"
aria-label="Choose color"
@keydown.escape.prevent="close"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<!-- Preset swatches -->
@@ -341,7 +384,9 @@ onBeforeUnmount(() => {
:key="c"
type="button"
@click="selectPreset(c)"
class="w-6 h-6 rounded-full border-2 transition-colors cursor-pointer hover:scale-110"
:aria-label="'Color preset ' + c"
:aria-pressed="currentHex === c.toUpperCase()"
class="w-8 h-8 rounded-full border-2 transition-colors cursor-pointer hover:scale-110"
:class="currentHex === c.toUpperCase() ? 'border-text-primary' : 'border-transparent'"
:style="{ backgroundColor: c }"
/>
@@ -351,11 +396,15 @@ onBeforeUnmount(() => {
<!-- Saturation/Brightness gradient -->
<div class="px-3 pb-2">
<div
role="application"
aria-label="Saturation and brightness"
tabindex="0"
class="relative rounded-lg overflow-hidden cursor-crosshair"
style="touch-action: none;"
@pointerdown="onGradientPointerDown"
@pointermove="onGradientPointerMove"
@pointerup="onGradientPointerUp"
@keydown="onGradientKeydown"
>
<canvas
ref="gradientRef"
@@ -378,11 +427,18 @@ onBeforeUnmount(() => {
<!-- Hue slider -->
<div class="px-3 pb-2">
<div
role="slider"
aria-label="Hue"
:aria-valuenow="Math.round(hue)"
aria-valuemin="0"
aria-valuemax="360"
tabindex="0"
class="relative rounded-md overflow-hidden cursor-pointer"
style="touch-action: none;"
@pointerdown="onHuePointerDown"
@pointermove="onHuePointerMove"
@pointerup="onHuePointerUp"
@keydown="onHueKeydown"
>
<canvas
ref="hueRef"
@@ -404,6 +460,8 @@ onBeforeUnmount(() => {
<!-- Hex input + preview -->
<div class="px-3 pb-3 flex items-center gap-2">
<span
role="img"
:aria-label="'Selected color: ' + currentHex"
class="w-8 h-8 rounded-lg border border-border-subtle shrink-0"
:style="{ backgroundColor: currentHex }"
/>
@@ -412,6 +470,7 @@ onBeforeUnmount(() => {
@input="onHexInput"
type="text"
maxlength="7"
aria-label="Hex color value"
class="flex-1 px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary font-mono tracking-wide placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="#D97706"
/>

View File

@@ -2,6 +2,8 @@
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { getLocaleCode } from '../utils/locale'
import { getFixedPositionMapping } from '../utils/dropdown'
import { useFocusTrap } from '../utils/focusTrap'
interface Props {
modelValue: string
@@ -24,6 +26,8 @@ const emit = defineEmits<{
'update:minute': [value: number]
}>()
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
const isOpen = ref(false)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
@@ -51,6 +55,9 @@ const displayText = computed(() => {
return datePart
})
// ── Reduced motion check ────────────────────────────────────────────
const scrollBehavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' as const : 'smooth' as const
// ── Time wheel ──────────────────────────────────────────────────────
const WHEEL_ITEM_H = 36
const WHEEL_VISIBLE = 5
@@ -102,7 +109,7 @@ function onHourWheel(e: WheelEvent) {
const dir = e.deltaY > 0 ? 1 : -1
const cur = Math.round(hourWheelRef.value.scrollTop / WHEEL_ITEM_H)
const next = Math.min(23, Math.max(0, cur + dir))
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: 'smooth' })
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
function onMinuteWheel(e: WheelEvent) {
@@ -111,7 +118,29 @@ function onMinuteWheel(e: WheelEvent) {
const dir = e.deltaY > 0 ? 1 : -1
const cur = Math.round(minuteWheelRef.value.scrollTop / WHEEL_ITEM_H)
const next = Math.min(59, Math.max(0, cur + dir))
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: 'smooth' })
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
// Keyboard support for time wheels
function onWheelKeydown(e: KeyboardEvent, type: 'hour' | 'minute') {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
e.preventDefault()
const dir = e.key === 'ArrowUp' ? -1 : 1
if (type === 'hour') {
const next = Math.min(23, Math.max(0, internalHour.value + dir))
internalHour.value = next
emit('update:hour', next)
if (hourWheelRef.value) {
hourWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
} else {
const next = Math.min(59, Math.max(0, internalMinute.value + dir))
internalMinute.value = next
emit('update:minute', next)
if (minuteWheelRef.value) {
minuteWheelRef.value.scrollTo({ top: next * WHEEL_ITEM_H, behavior: scrollBehavior })
}
}
}
// Click-and-drag support
@@ -141,7 +170,7 @@ function onWheelPointerUp(e: PointerEvent) {
el.releasePointerCapture(e.pointerId)
// Snap to nearest item
const index = Math.round(el.scrollTop / WHEEL_ITEM_H)
el.scrollTo({ top: index * WHEEL_ITEM_H, behavior: 'smooth' })
el.scrollTo({ top: index * WHEEL_ITEM_H, behavior: scrollBehavior })
}
function scrollWheelsToTime() {
@@ -234,33 +263,28 @@ const dayCells = computed<DayCell[]>(() => {
const dayHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
// ── Positioning ─────────────────────────────────────────────────────
function getZoomFactor(): number {
const app = document.getElementById('app')
if (!app) return 1
const zoom = (app.style as any).zoom
return zoom ? parseFloat(zoom) / 100 : 1
}
function updatePosition() {
if (!triggerRef.value) return
const rect = triggerRef.value.getBoundingClientRect()
const zoom = getZoomFactor()
const panelWidth = props.showTime ? 390 : 280
const renderedWidth = panelWidth * zoom
const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
const gap = 4
let leftViewport = rect.left
if (leftViewport + renderedWidth > window.innerWidth) {
leftViewport = window.innerWidth - renderedWidth - 8
const panelWidth = props.showTime ? 390 : 280
const estW = panelWidth * scaleX
const vpW = window.innerWidth
let leftVP = rect.left
if (leftVP + estW > vpW - gap) {
leftVP = vpW - estW - gap
}
if (leftViewport < 0) leftViewport = 0
if (leftVP < gap) leftVP = gap
panelStyle.value = {
position: 'fixed',
top: `${(rect.bottom + 4) / zoom}px`,
left: `${leftViewport / zoom}px`,
top: `${(rect.bottom + gap - offsetY) / scaleY}px`,
left: `${(leftVP - offsetX) / scaleX}px`,
width: `${panelWidth}px`,
zIndex: '9999',
zoom: `${zoom * 100}%`,
}
}
@@ -295,10 +319,12 @@ function open() {
if (props.showTime) {
scrollWheelsToTime()
}
if (panelRef.value) activateTrap(panelRef.value)
})
}
function close() {
deactivateTrap()
isOpen.value = false
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
@@ -385,6 +411,8 @@ onBeforeUnmount(() => {
ref="triggerRef"
type="button"
@click="toggle"
:aria-expanded="isOpen"
aria-haspopup="dialog"
class="w-full flex items-center justify-between gap-2 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
:style="
isOpen
@@ -403,18 +431,23 @@ onBeforeUnmount(() => {
{{ displayText ?? placeholder }}
</span>
<Calendar
aria-hidden="true"
class="w-4 h-4 text-text-secondary shrink-0"
:stroke-width="2"
/>
</button>
<!-- Calendar popover -->
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="dialog"
aria-modal="true"
aria-label="Date picker"
@keydown.escape.prevent="close"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<!-- Month/year header -->
@@ -422,9 +455,11 @@ onBeforeUnmount(() => {
<button
type="button"
@click="prevMonthNav"
aria-label="Previous month"
v-tooltip="'Previous month'"
class="p-1 rounded-lg hover:bg-bg-elevated transition-colors cursor-pointer text-text-secondary hover:text-text-primary"
>
<ChevronLeft class="w-4 h-4" :stroke-width="2" />
<ChevronLeft aria-hidden="true" class="w-4 h-4" :stroke-width="2" />
</button>
<span class="text-[0.8125rem] font-medium text-text-primary select-none">
{{ viewMonthLabel }}
@@ -432,9 +467,11 @@ onBeforeUnmount(() => {
<button
type="button"
@click="nextMonthNav"
aria-label="Next month"
v-tooltip="'Next month'"
class="p-1 rounded-lg hover:bg-bg-elevated transition-colors cursor-pointer text-text-secondary hover:text-text-primary"
>
<ChevronRight class="w-4 h-4" :stroke-width="2" />
<ChevronRight aria-hidden="true" class="w-4 h-4" :stroke-width="2" />
</button>
</div>
@@ -443,10 +480,11 @@ onBeforeUnmount(() => {
<!-- Calendar column -->
<div class="flex-1 min-w-0">
<!-- Day-of-week headers -->
<div class="grid grid-cols-7 px-2">
<div class="grid grid-cols-7 px-2" role="row">
<div
v-for="header in dayHeaders"
:key="header"
role="columnheader"
class="text-center text-[0.6875rem] font-medium text-text-tertiary py-1 select-none"
>
{{ header }}
@@ -454,13 +492,15 @@ onBeforeUnmount(() => {
</div>
<!-- Day grid -->
<div class="grid grid-cols-7 px-2 pb-2">
<div class="grid grid-cols-7 px-2 pb-2" role="grid" aria-label="Calendar days">
<button
v-for="(cell, index) in dayCells"
:key="index"
type="button"
:disabled="!cell.isCurrentMonth"
@click="selectDay(cell)"
:aria-label="new Date(cell.year, cell.month, cell.date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })"
:aria-selected="cell.isCurrentMonth ? cell.dateString === modelValue : undefined"
class="relative flex items-center justify-center h-8 w-full text-[0.75rem] rounded-lg transition-colors select-none"
:class="[
!cell.isCurrentMonth
@@ -493,10 +533,17 @@ onBeforeUnmount(() => {
<!-- Scrollable wheel -->
<div
ref="hourWheelRef"
tabindex="0"
role="spinbutton"
:aria-valuenow="internalHour"
aria-valuemin="0"
aria-valuemax="23"
aria-label="Hour"
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
@scroll="onHourScroll"
@wheel.prevent="onHourWheel"
@keydown="onWheelKeydown($event, 'hour')"
@pointerdown.prevent="onWheelPointerDown"
@pointermove="onWheelPointerMove"
@pointerup="onWheelPointerUp"
@@ -530,10 +577,17 @@ onBeforeUnmount(() => {
<!-- Scrollable wheel -->
<div
ref="minuteWheelRef"
tabindex="0"
role="spinbutton"
:aria-valuenow="internalMinute"
aria-valuemin="0"
aria-valuemax="59"
aria-label="Minute"
class="absolute inset-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden cursor-grab active:cursor-grabbing"
style="touch-action: none; -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, transparent 0%, black 25%, black 75%, transparent 100%);"
@scroll="onMinuteScroll"
@wheel.prevent="onMinuteWheel"
@keydown="onWheelKeydown($event, 'minute')"
@pointerdown.prevent="onWheelPointerDown"
@pointermove="onWheelPointerMove"
@pointerup="onWheelPointerUp"

View File

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

View File

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

View File

@@ -10,6 +10,8 @@ interface Props {
precision?: number
prefix?: string
suffix?: string
label?: string
compact?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -19,6 +21,8 @@ const props = withDefaults(defineProps<Props>(), {
precision: 0,
prefix: '',
suffix: '',
label: 'Number input',
compact: false,
})
const emit = defineEmits<{
@@ -84,24 +88,39 @@ function cancelEdit() {
</script>
<template>
<div class="flex items-center gap-2">
<div
class="flex items-center"
:class="compact ? 'gap-1' : 'gap-2'"
role="group"
:aria-label="label"
>
<button
type="button"
aria-label="Decrease value"
v-tooltip="'Decrease'"
@mousedown.prevent="startHold(decrement)"
@mouseup="stopHold"
@mouseleave="stopHold"
@touchstart.prevent="startHold(decrement)"
@touchend="stopHold"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
class="flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
:class="compact ? 'w-6 h-6' : 'w-8 h-8'"
:disabled="modelValue <= min"
>
<Minus class="w-3.5 h-3.5" :stroke-width="2" />
<Minus :class="compact ? 'w-3 h-3' : 'w-3.5 h-3.5'" aria-hidden="true" :stroke-width="2" />
</button>
<div
v-if="!isEditing"
@click="startEdit"
class="min-w-[4rem] text-center text-[0.8125rem] font-mono text-text-primary cursor-text select-none"
@keydown.enter="startEdit"
@keydown.space.prevent="startEdit"
tabindex="0"
role="button"
:aria-label="'Edit value: ' + displayValue"
class="text-center font-mono text-text-primary cursor-text select-none"
:class="compact ? 'min-w-[2.5rem] text-[0.75rem]' : 'min-w-[4rem] text-[0.8125rem]'"
aria-live="polite"
>
<span v-if="prefix" class="text-text-tertiary">{{ prefix }}</span>
{{ displayValue }}
@@ -113,7 +132,9 @@ function cancelEdit() {
v-model="editValue"
type="text"
inputmode="decimal"
class="w-20 text-center px-1 py-0.5 bg-bg-inset border border-accent rounded-lg text-[0.8125rem] font-mono text-text-primary focus:outline-none"
:aria-label="label"
class="text-center px-1 py-0.5 bg-bg-inset border border-accent rounded-lg font-mono text-text-primary focus:outline-none"
:class="compact ? 'w-16 text-[0.75rem]' : 'w-20 text-[0.8125rem]'"
@blur="commitEdit"
@keydown.enter="commitEdit"
@keydown.escape="cancelEdit"
@@ -121,15 +142,18 @@ function cancelEdit() {
<button
type="button"
aria-label="Increase value"
v-tooltip="'Increase'"
@mousedown.prevent="startHold(increment)"
@mouseup="stopHold"
@mouseleave="stopHold"
@touchstart.prevent="startHold(increment)"
@touchend="stopHold"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
class="flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
:class="compact ? 'w-6 h-6' : 'w-8 h-8'"
:disabled="modelValue >= max"
>
<Plus class="w-3.5 h-3.5" :stroke-width="2" />
<Plus :class="compact ? 'w-3 h-3' : 'w-3.5 h-3.5'" aria-hidden="true" :stroke-width="2" />
</button>
</div>
</template>

View File

@@ -12,6 +12,7 @@ interface Props {
disabled?: boolean
placeholderValue?: any
searchable?: boolean
ariaLabelledby?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -27,6 +28,7 @@ const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const listboxId = 'appselect-lb-' + Math.random().toString(36).slice(2, 9)
const isOpen = ref(false)
const highlightedIndex = ref(-1)
const triggerRef = ref<HTMLButtonElement | null>(null)
@@ -80,7 +82,10 @@ function isSelected(item: any): boolean {
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { estimatedHeight: 280 })
panelStyle.value = computeDropdownPosition(triggerRef.value, {
estimatedHeight: 280,
panelEl: panelRef.value,
})
}
function toggle() {
@@ -108,6 +113,8 @@ function open() {
nextTick(() => {
scrollHighlightedIntoView()
// Reposition with actual panel height (fixes above-flip offset)
updatePosition()
})
document.addEventListener('click', onClickOutside, true)
@@ -220,6 +227,12 @@ onBeforeUnmount(() => {
<button
ref="triggerRef"
type="button"
role="combobox"
:aria-expanded="isOpen"
aria-haspopup="listbox"
:aria-activedescendant="isOpen && highlightedIndex >= 0 ? 'appselect-option-' + highlightedIndex : undefined"
:aria-labelledby="ariaLabelledby"
:aria-controls="isOpen ? listboxId : undefined"
:disabled="disabled"
@click="toggle"
@keydown="onKeydown"
@@ -238,13 +251,16 @@ onBeforeUnmount(() => {
: {}
"
>
<span
:class="isPlaceholderSelected ? 'text-text-tertiary' : 'text-text-primary'"
class="truncate"
>
{{ selectedLabel ?? placeholder }}
</span>
<slot name="selected" :label="selectedLabel ?? placeholder" :is-placeholder="isPlaceholderSelected">
<span
:class="isPlaceholderSelected ? 'text-text-tertiary' : 'text-text-primary'"
class="truncate"
>
{{ selectedLabel ?? placeholder }}
</span>
</slot>
<ChevronDown
aria-hidden="true"
class="w-4 h-4 text-text-secondary shrink-0 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"
:stroke-width="2"
@@ -252,7 +268,7 @@ onBeforeUnmount(() => {
</button>
<!-- Dropdown panel -->
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
@@ -265,15 +281,19 @@ onBeforeUnmount(() => {
ref="searchInputRef"
v-model="searchQuery"
type="text"
aria-label="Search options"
class="w-full px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="Search..."
@keydown="onSearchKeydown"
/>
</div>
<div class="max-h-[240px] overflow-y-auto py-1">
<div class="max-h-[240px] overflow-y-auto py-1" role="listbox" :id="listboxId">
<div
v-for="(item, index) in filteredItems"
:key="item._isPlaceholder ? '__placeholder__' : item[valueKey]"
role="option"
:id="'appselect-option-' + index"
:aria-selected="isSelected(item)"
data-option
@click="select(item)"
@mouseenter="highlightedIndex = index"
@@ -284,9 +304,12 @@ onBeforeUnmount(() => {
'text-text-primary': !item._isPlaceholder,
}"
>
<span class="truncate">{{ getOptionLabel(item) }}</span>
<slot name="option" :item="item" :label="getOptionLabel(item)" :selected="isSelected(item)">
<span class="truncate">{{ getOptionLabel(item) }}</span>
</slot>
<Check
v-if="isSelected(item)"
aria-hidden="true"
class="w-4 h-4 text-accent shrink-0"
:stroke-width="2"
/>

View File

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

View File

@@ -21,6 +21,7 @@ const triggerRef = ref<HTMLDivElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
const inputRef = ref<HTMLInputElement | null>(null)
const highlightedIndex = ref(-1)
const selectedTags = computed(() => {
return tagsStore.tags.filter(t => t.id && props.modelValue.includes(t.id))
@@ -69,14 +70,17 @@ async function createAndAdd() {
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 200, estimatedHeight: 200 })
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 200, estimatedHeight: 200, panelEl: panelRef.value })
}
function open() {
isOpen.value = true
searchQuery.value = ''
updatePosition()
nextTick(() => inputRef.value?.focus())
nextTick(() => {
updatePosition()
inputRef.value?.focus()
})
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
@@ -99,6 +103,30 @@ function onScrollOrResize() {
if (isOpen.value) updatePosition()
}
function onSearchKeydown(e: KeyboardEvent) {
const total = filteredTags.value.length + (showCreateOption.value ? 1 : 0)
if (e.key === 'ArrowDown') {
e.preventDefault()
highlightedIndex.value = Math.min(highlightedIndex.value + 1, total - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, -1)
} else if (e.key === 'Enter' && highlightedIndex.value >= 0) {
e.preventDefault()
if (highlightedIndex.value < filteredTags.value.length) {
const tag = filteredTags.value[highlightedIndex.value]
toggleTag(tag.id!)
searchQuery.value = ''
} else if (showCreateOption.value) {
createAndAdd()
}
highlightedIndex.value = -1
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
@@ -116,30 +144,34 @@ onBeforeUnmount(() => {
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[0.6875rem] text-text-primary"
:style="{ backgroundColor: tag.color + '22', borderColor: tag.color + '44', border: '1px solid' }"
>
<span class="w-1.5 h-1.5 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="w-1.5 h-1.5 rounded-full" :style="{ backgroundColor: tag.color }" aria-hidden="true" />
{{ tag.name }}
<button @click.stop="removeTag(tag.id!)" class="ml-0.5 hover:text-status-error">
<X class="w-2.5 h-2.5" />
<button @click.stop="removeTag(tag.id!)" :aria-label="'Remove tag ' + tag.name" v-tooltip="'Remove tag'" class="ml-0.5 min-w-[24px] min-h-[24px] flex items-center justify-center hover:text-status-error">
<X class="w-2.5 h-2.5" aria-hidden="true" />
</button>
</span>
<button
key="__add_btn__"
type="button"
@click="isOpen ? close() : open()"
aria-label="Add tag"
:aria-expanded="isOpen"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[0.6875rem] text-text-tertiary border border-border-subtle hover:text-text-secondary hover:border-border-visible transition-colors"
>
<Plus class="w-3 h-3" />
<Plus class="w-3 h-3" aria-hidden="true" />
Tag
</button>
</TransitionGroup>
<!-- Dropdown -->
<Teleport to="body">
<Teleport to="#app">
<Transition name="dropdown">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
role="listbox"
aria-label="Tag options"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
>
<div class="px-2 pt-2 pb-1">
@@ -147,26 +179,33 @@ onBeforeUnmount(() => {
ref="inputRef"
v-model="searchQuery"
type="text"
aria-label="Search or create tag"
@keydown="onSearchKeydown"
class="w-full px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="Search or create tag..."
/>
</div>
<div class="max-h-[160px] overflow-y-auto py-1">
<div
v-for="tag in filteredTags"
v-for="(tag, index) in filteredTags"
:key="tag.id"
:id="'tag-option-' + tag.id"
role="option"
@click="toggleTag(tag.id!); searchQuery = ''"
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-bg-elevated transition-colors"
:class="{ 'bg-bg-elevated': index === highlightedIndex }"
>
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: tag.color }" aria-hidden="true" />
<span class="text-[0.75rem] text-text-primary">{{ tag.name }}</span>
</div>
<div
v-if="showCreateOption"
role="option"
@click="createAndAdd"
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-bg-elevated transition-colors text-accent-text"
:class="{ 'bg-bg-elevated': filteredTags.length === highlightedIndex }"
>
<Plus class="w-3 h-3" />
<Plus class="w-3 h-3" aria-hidden="true" />
<span class="text-[0.75rem]">Create "{{ searchQuery.trim() }}"</span>
</div>
<div v-if="filteredTags.length === 0 && !showCreateOption" class="px-3 py-3 text-center text-[0.75rem] text-text-tertiary">

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { useFocusTrap } from '../utils/focusTrap'
import { useAnnouncer } from '../composables/useAnnouncer'
import { useEntryTemplatesStore, type EntryTemplate } from '../stores/entryTemplates'
import { useProjectsStore } from '../stores/projects'
import { X, FileText } from 'lucide-vue-next'
import { X, FileText, Pencil, Trash2 } from 'lucide-vue-next'
const props = defineProps<{
show: boolean
@@ -68,10 +68,46 @@ function onKeydown(e: KeyboardEvent) {
function selectTemplate(tpl: EntryTemplate) {
emit('select', tpl)
}
const editingId = ref<number | null>(null)
const editForm = ref({ name: '', project_id: 0, duration: 0 })
const confirmDeleteId = ref<number | null>(null)
function startEdit(tpl: EntryTemplate) {
editingId.value = tpl.id!
editForm.value = { name: tpl.name, project_id: tpl.project_id, duration: tpl.duration || 0 }
confirmDeleteId.value = null
}
function cancelEdit() {
editingId.value = null
}
async function saveEdit(tpl: EntryTemplate) {
await templatesStore.updateTemplate({
...tpl,
name: editForm.value.name,
project_id: editForm.value.project_id,
duration: editForm.value.duration,
})
editingId.value = null
announce('Template updated')
}
function confirmDelete(id: number) {
confirmDeleteId.value = id
editingId.value = null
}
async function executeDelete(id: number) {
await templatesStore.deleteTemplate(id)
confirmDeleteId.value = null
announce('Template deleted')
}
</script>
<template>
<Teleport to="body">
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
@@ -94,29 +130,102 @@ function selectTemplate(tpl: EntryTemplate) {
@click="$emit('cancel')"
class="p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close"
v-tooltip="'Close'"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div v-if="templatesStore.templates.length > 0" class="space-y-1 max-h-64 overflow-y-auto" role="listbox" aria-label="Entry templates">
<button
<div
v-for="(tpl, i) in templatesStore.templates"
:key="tpl.id"
@click="selectTemplate(tpl)"
role="option"
:aria-selected="i === activeIndex"
:class="[
'w-full text-left px-3 py-2 rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
i === activeIndex ? 'bg-accent-muted' : 'hover:bg-bg-elevated'
]"
>
<p class="text-[0.8125rem] text-text-primary">{{ tpl.name }}</p>
<p class="text-[0.6875rem] text-text-tertiary">
{{ getProjectName(tpl.project_id) }}
<span v-if="tpl.duration"> - {{ formatDuration(tpl.duration) }}</span>
</p>
</button>
<!-- Delete confirmation -->
<div v-if="confirmDeleteId === tpl.id" class="px-3 py-2 rounded-lg bg-status-error/10 border border-status-error/20">
<p class="text-[0.8125rem] text-text-primary mb-2">Delete this template?</p>
<div class="flex gap-2">
<button
@click="executeDelete(tpl.id!)"
class="px-3 py-1.5 text-[0.75rem] font-medium bg-status-error text-white rounded-lg hover:bg-status-error/90 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Delete
</button>
<button
@click="confirmDeleteId = null"
class="px-3 py-1.5 text-[0.75rem] font-medium text-text-secondary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Cancel
</button>
</div>
</div>
<!-- Edit mode -->
<div v-else-if="editingId === tpl.id" class="px-3 py-2 rounded-lg bg-bg-elevated space-y-2">
<input
v-model="editForm.name"
class="w-full px-2 py-1.5 text-[0.8125rem] bg-bg-base border border-border-subtle rounded-lg text-text-primary focus:outline-2 focus:outline-accent"
placeholder="Template name"
/>
<select
v-model="editForm.project_id"
class="w-full px-2 py-1.5 text-[0.8125rem] bg-bg-base border border-border-subtle rounded-lg text-text-primary focus:outline-2 focus:outline-accent"
>
<option v-for="p in projectsStore.activeProjects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<div class="flex gap-2">
<button
@click="saveEdit(tpl)"
class="px-3 py-1.5 text-[0.75rem] font-medium bg-accent text-white rounded-lg hover:bg-accent/90 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Save
</button>
<button
@click="cancelEdit"
class="px-3 py-1.5 text-[0.75rem] font-medium text-text-secondary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Cancel
</button>
</div>
</div>
<!-- Normal display -->
<div v-else class="flex items-center group">
<button
@click="selectTemplate(tpl)"
role="option"
:aria-selected="i === activeIndex"
:class="[
'flex-1 text-left px-3 py-2 rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
i === activeIndex ? 'bg-accent-muted' : 'hover:bg-bg-elevated'
]"
>
<p class="text-[0.8125rem] text-text-primary">{{ tpl.name }}</p>
<p class="text-[0.6875rem] text-text-tertiary">
{{ getProjectName(tpl.project_id) }}
<span v-if="tpl.duration"> - {{ formatDuration(tpl.duration) }}</span>
</p>
</button>
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 pr-1">
<button
@click.stop="startEdit(tpl)"
class="p-1.5 text-text-tertiary hover:text-text-primary transition-colors rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Edit template"
v-tooltip="'Edit'"
>
<Pencil class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="1.5" />
</button>
<button
@click.stop="confirmDelete(tpl.id!)"
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Delete template"
v-tooltip="'Delete'"
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" :stroke-width="1.5" />
</button>
</div>
</div>
</div>
</div>
<div v-else class="py-8 text-center">

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,6 +58,7 @@ void clientAddress
<img
v-if="biz?.logo"
:src="biz.logo"
alt="Business logo"
:style="{ width: '28px', height: '28px', objectFit: 'contain', marginBottom: '4px', display: 'block' }"
/>
<div :style="{ fontSize: '13px', fontWeight: '600', color: c.headerText }">
@@ -102,10 +103,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -172,7 +173,7 @@ void clientAddress
</div>
</div>
<div :style="{ textAlign: 'right' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<div :style="{ fontSize: '12px', fontWeight: '600' }">{{ biz?.name || 'Your Business' }}</div>
</div>
</div>
@@ -201,10 +202,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -268,7 +269,7 @@ void clientAddress
</div>
<!-- Biz info outside the block -->
<div :style="{ flex: 1, padding: '5% 5%', display: 'flex', flexDirection: 'column', justifyContent: 'center' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '9px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'blf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
@@ -303,10 +304,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -364,7 +365,7 @@ void clientAddress
<div :style="{ padding: '6%' }">
<!-- Centered header -->
<div :style="{ textAlign: 'center', marginBottom: '10px' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', margin: '0 auto 6px', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', margin: '0 auto 6px', display: 'block' }" />
<div :style="{ height: '1px', backgroundColor: '#e4e4e7', marginBottom: '8px' }" />
<div :style="{ fontSize: '20px', fontWeight: '700', color: '#18181b', letterSpacing: '0.04em' }">INVOICE</div>
<div :style="{ fontSize: '9px', fontWeight: '600', color: '#18181b', marginTop: '2px' }">{{ biz?.name || 'Your Business' }}</div>
@@ -399,10 +400,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr>
<th :style="{ textAlign: 'left', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', letterSpacing: '0.03em' }">Description</th>
<th :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', letterSpacing: '0.03em' }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '6px 4px', fontSize: '7px', fontWeight: '700', textTransform: 'uppercase', color: '#18181b', width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -461,7 +462,7 @@ void clientAddress
<!-- Traditional two-column header -->
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }">
<div>
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '11px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'clf'+i">{{ line }}</div>
<div v-if="biz?.email">{{ biz.email }}</div>
@@ -491,10 +492,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px', border: '1px solid ' + c.tableBorder }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
</tr>
</thead>
<tbody>
@@ -560,7 +561,7 @@ void clientAddress
<div v-if="invoice.due_date" :style="{ fontSize: '7.5px', color: c.bodyText }">Due {{ formatDate(invoice.due_date) }}</div>
</div>
<div :style="{ textAlign: 'right' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<div :style="{ fontSize: '10px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'modf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
@@ -591,10 +592,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr>
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, borderBottom: '1px solid ' + c.primary }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', borderBottom: '1px solid ' + c.primary }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', borderBottom: '1px solid ' + c.primary }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', borderBottom: '1px solid ' + c.primary }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, borderBottom: '1px solid ' + c.primary }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', borderBottom: '1px solid ' + c.primary }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', borderBottom: '1px solid ' + c.primary }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', borderBottom: '1px solid ' + c.primary }">Amount</th>
</tr>
</thead>
<tbody>
@@ -659,7 +660,7 @@ void clientAddress
<!-- Centered header -->
<div :style="{ textAlign: 'center', marginBottom: '8px' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', margin: '0 auto 4px', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', margin: '0 auto 4px', display: 'block' }" />
<div :style="{ fontSize: '20px', fontWeight: '700', color: c.headerText, letterSpacing: '0.06em' }">INVOICE</div>
<div :style="{ fontSize: '10px', fontWeight: '600', color: c.headerText, marginTop: '2px' }">{{ biz?.name || 'Your Business' }}</div>
<div :style="{ fontSize: '7.5px', marginTop: '2px' }">
@@ -697,20 +698,20 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr>
<th :colspan="4" :style="{ padding: 0, height: '0' }">
<th scope="col" :colspan="4" :style="{ padding: 0, height: '0' }">
<div :style="{ height: '1px', backgroundColor: c.primary }" />
<div :style="{ height: '1.5px' }" />
<div :style="{ height: '1px', backgroundColor: c.primary }" />
</th>
</tr>
<tr>
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
<tr>
<th :colspan="4" :style="{ padding: 0, height: '0' }">
<th scope="col" :colspan="4" :style="{ padding: 0, height: '0' }">
<div :style="{ height: '1px', backgroundColor: c.primary }" />
<div :style="{ height: '1.5px' }" />
<div :style="{ height: '1px', backgroundColor: c.primary }" />
@@ -776,7 +777,7 @@ void clientAddress
<div :style="{ paddingLeft: '7%', paddingRight: '5%', paddingTop: '5%', paddingBottom: '5%' }">
<!-- Logo + Title -->
<div :style="{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain' }" />
<div>
<div :style="{ fontSize: '20px', fontWeight: '700', color: c.primary }">INVOICE</div>
<div :style="{ fontSize: '7.5px' }">#{{ invoice.invoice_number }}</div>
@@ -909,10 +910,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '10px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '38px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '46px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '52px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '38px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '46px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '3px 3px', fontSize: '6.5px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '52px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -975,7 +976,7 @@ void clientAddress
<div :style="{ width: '40px', height: '1px', backgroundColor: c.primary, marginTop: '4px' }" />
</div>
<div :style="{ textAlign: 'right' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<div :style="{ fontSize: '10px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
</div>
</div>
@@ -1008,10 +1009,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1077,7 +1078,7 @@ void clientAddress
</div>
</div>
<div :style="{ textAlign: 'right' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px', marginLeft: 'auto', display: 'block' }" />
<div :style="{ fontSize: '11px', fontWeight: '600' }">{{ biz?.name || 'Your Business' }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px', opacity: 0.9 }">{{ biz.email }}</div>
</div>
@@ -1106,10 +1107,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1167,7 +1168,7 @@ void clientAddress
<!-- Deep blue band -->
<div :style="{ backgroundColor: c.headerBg, color: c.headerText, padding: '4% 6% 3%', minHeight: '12%', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }">
<div>
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '2px' }" />
<div :style="{ fontSize: '12px', fontWeight: '600' }">{{ biz?.name || 'Your Business' }}</div>
</div>
<div :style="{ fontSize: '22px', fontWeight: '700' }">INVOICE</div>
@@ -1204,10 +1205,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px', border: '1px solid ' + c.tableBorder }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, border: '1px solid ' + c.tableBorder }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px', border: '1px solid ' + c.tableBorder }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px', border: '1px solid ' + c.tableBorder }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px', border: '1px solid ' + c.tableBorder }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1266,7 +1267,7 @@ void clientAddress
<!-- Header with logo left, watermark invoice number right -->
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px', position: 'relative' }">
<div>
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '26px', height: '26px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '11px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'frf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
@@ -1307,10 +1308,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1369,7 +1370,7 @@ void clientAddress
<!-- Header -->
<div :style="{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }">
<div>
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '24px', height: '24px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '20px', fontWeight: '700', color: c.primary }">INVOICE</div>
</div>
<div :style="{ textAlign: 'right' }">
@@ -1407,10 +1408,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr :style="{ backgroundColor: c.tableHeaderBg }">
<th :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '4px 4px', fontSize: '7px', fontWeight: '500', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>
@@ -1482,7 +1483,7 @@ void clientAddress
<!-- Biz info -->
<div :style="{ marginBottom: '10px' }">
<img v-if="biz?.logo" :src="biz.logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
<img v-if="biz?.logo" :src="biz.logo" alt="Business logo" :style="{ width: '22px', height: '22px', objectFit: 'contain', marginBottom: '3px' }" />
<div :style="{ fontSize: '9px', fontWeight: '600', color: c.headerText }">{{ biz?.name || 'Your Business' }}</div>
<div v-for="(line, i) in bizAddressLines" :key="'sf'+i" :style="{ fontSize: '7px' }">{{ line }}</div>
<div v-if="biz?.email" :style="{ fontSize: '7px' }">{{ biz.email }}</div>
@@ -1509,10 +1510,10 @@ void clientAddress
<table :style="{ width: '100%', borderCollapse: 'collapse', marginBottom: '12px' }">
<thead>
<tr>
<th :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
<th scope="col" :style="{ textAlign: 'left', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText }">Description</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '42px' }">Qty</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '50px' }">Rate</th>
<th scope="col" :style="{ textAlign: 'right', padding: '5px 4px', fontSize: '7px', fontWeight: '600', textTransform: 'uppercase', color: c.tableHeaderText, width: '58px' }">Amount</th>
</tr>
</thead>
<tbody>

View File

@@ -93,16 +93,18 @@ function selectTemplate(id: string) {
style="height: 480px"
>
<!-- Left panel: Template list -->
<div class="w-[30%] border-r border-border-subtle overflow-y-auto bg-bg-surface">
<div class="w-[30%] border-r border-border-subtle overflow-y-auto bg-bg-surface" role="radiogroup" aria-label="Invoice templates">
<div v-for="cat in TEMPLATE_CATEGORIES" :key="cat.id">
<div
class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.1em] font-medium px-3 pt-3 pb-1"
class="text-[0.5625rem] text-text-tertiary uppercase tracking-[0.08em] font-medium px-3 pt-3 pb-1"
>
{{ cat.label }}
</div>
<button
v-for="tmpl in getTemplatesByCategory(cat.id)"
:key="tmpl.id"
role="radio"
:aria-checked="tmpl.id === modelValue"
class="w-full flex items-center gap-2 px-3 py-1.5 text-[0.75rem] transition-colors"
:class="
tmpl.id === modelValue
@@ -114,6 +116,7 @@ function selectTemplate(id: string) {
<span
class="w-2.5 h-2.5 rounded-full shrink-0 border border-black/10"
:style="{ backgroundColor: tmpl.colors.primary }"
aria-hidden="true"
/>
<span class="truncate">{{ tmpl.name }}</span>
</button>
@@ -121,7 +124,7 @@ function selectTemplate(id: string) {
</div>
<!-- Right panel: Live preview -->
<div class="w-[70%] bg-bg-inset p-4 flex items-start justify-center overflow-y-auto">
<div class="w-[70%] bg-bg-inset p-4 flex items-start justify-center overflow-y-auto" aria-label="Template preview" aria-live="polite">
<div class="w-full max-w-sm">
<InvoicePreview
:template="selectedTemplate"

View File

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

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { watch, ref } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: [] }>()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
watch(() => props.show, (val) => {
if (val) {
setTimeout(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
}, 50)
} else {
deactivate()
}
})
const groups = [
{
label: 'Global',
shortcuts: [
{ keys: '?', description: 'Show keyboard shortcuts' },
{ keys: 'Ctrl+Shift+T', description: 'Toggle timer' },
{ keys: 'Ctrl+Shift+Z', description: 'Show/focus app' },
{ keys: 'Ctrl+Shift+N', description: 'Quick entry' },
]
},
{
label: 'Timer',
shortcuts: [
{ keys: 'Space', description: 'Start/stop timer (when focused)' },
]
},
{
label: 'Navigation',
shortcuts: [
{ keys: 'Arrow keys', description: 'Navigate tabs, calendar, lists' },
{ keys: 'Enter', description: 'Open/select focused item' },
{ keys: 'Escape', description: 'Close dialog/popover' },
]
},
]
</script>
<template>
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="emit('close')"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title"
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6"
>
<h2 id="shortcuts-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
Keyboard Shortcuts
</h2>
<div class="space-y-4">
<div v-for="group in groups" :key="group.label">
<h3 class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] font-medium mb-2">
{{ group.label }}
</h3>
<div class="space-y-1.5">
<div
v-for="shortcut in group.shortcuts"
:key="shortcut.keys"
class="flex items-center justify-between text-[0.8125rem]"
>
<span class="text-text-secondary">{{ shortcut.description }}</span>
<kbd class="px-2 py-0.5 bg-bg-elevated border border-border-subtle rounded text-[0.6875rem] font-mono text-text-primary">
{{ shortcut.keys }}
</kbd>
</div>
</div>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
@click="emit('close')"
class="px-4 py-2 border border-border-subtle text-text-secondary text-[0.8125rem] rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Close
</button>
</div>
</div>
</div>
</Transition>
</template>

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTimerStore } from '../stores/timer'
import { useSettingsStore } from '../stores/settings'
import { useInvoicesStore } from '../stores/invoices'
import {
LayoutDashboard,
Clock,
@@ -12,78 +14,250 @@ import {
Grid3X3,
BarChart3,
FileText,
Settings
Settings,
PanelLeftOpen,
PanelLeftClose
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const timerStore = useTimerStore()
const settingsStore = useSettingsStore()
const invoicesStore = useInvoicesStore()
const navItems = [
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
{ name: 'Timer', path: '/timer', icon: Clock },
{ name: 'Clients', path: '/clients', icon: Users },
{ name: 'Projects', path: '/projects', icon: FolderKanban },
{ name: 'Entries', path: '/entries', icon: List },
{ name: 'Calendar', path: '/calendar', icon: CalendarDays },
{ name: 'Timesheet', path: '/timesheet', icon: Grid3X3 },
{ name: 'Invoices', path: '/invoices', icon: FileText },
{ name: 'Reports', path: '/reports', icon: BarChart3 },
{ name: 'Settings', path: '/settings', icon: Settings }
const expanded = ref(false)
interface NavItem {
name: string
path: string
icon: any
}
interface NavGroup {
label: string
items: NavItem[]
}
const groups: NavGroup[] = [
{
label: 'Track',
items: [
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
{ name: 'Timer', path: '/timer', icon: Clock },
],
},
{
label: 'Manage',
items: [
{ name: 'Clients', path: '/clients', icon: Users },
{ name: 'Projects', path: '/projects', icon: FolderKanban },
{ name: 'Entries', path: '/entries', icon: List },
],
},
{
label: 'Views',
items: [
{ name: 'Calendar', path: '/calendar', icon: CalendarDays },
{ name: 'Timesheet', path: '/timesheet', icon: Grid3X3 },
],
},
{
label: 'Business',
items: [
{ name: 'Invoices', path: '/invoices', icon: FileText },
{ name: 'Reports', path: '/reports', icon: BarChart3 },
],
},
]
const settingsItem: NavItem = { name: 'Settings', path: '/settings', icon: Settings }
const currentPath = computed(() => route.path)
const activeIndex = computed(() => {
return navItems.findIndex(item => item.path === currentPath.value)
})
// Only show tooltips when nav is collapsed (labels are hidden)
function tip(text: string) {
return expanded.value ? '' : text
}
function navigate(path: string) {
router.push(path)
}
async function toggleExpanded() {
expanded.value = !expanded.value
await settingsStore.updateSetting('nav_expanded', expanded.value ? 'true' : 'false')
}
// Watch for settings to load (NavRail mounts before App.vue's fetchSettings resolves)
watch(() => settingsStore.settings.nav_expanded, (val) => {
if (val !== undefined) {
expanded.value = val === 'true'
}
}, { immediate: true })
</script>
<template>
<nav class="w-12 flex flex-col items-center bg-bg-surface border-r border-border-subtle shrink-0">
<div class="relative flex-1 flex flex-col items-center pt-2 gap-1">
<!-- Sliding active indicator -->
<nav
aria-label="Main navigation"
class="flex flex-col bg-bg-surface border-r border-border-subtle shrink-0 transition-[width] duration-200 ease-out overflow-hidden"
:class="expanded ? 'w-[180px]' : 'w-12'"
>
<!-- Scrollable nav items -->
<div class="relative flex-1 flex flex-col pt-1 overflow-y-auto overflow-x-hidden">
<div
v-if="activeIndex >= 0"
class="absolute left-0 w-[2px] bg-accent transition-all duration-300"
:style="{ top: `${activeIndex * 52 + 8 + 8}px`, height: '36px' }"
style="transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);"
/>
<button
v-for="item in navItems"
:key="item.path"
@click="navigate(item.path)"
class="relative w-12 h-[52px] flex items-center justify-center transition-colors duration-150 group"
:class="currentPath === item.path
? 'text-text-primary'
: 'text-text-tertiary hover:text-text-secondary'"
:title="item.name"
v-for="(group, groupIndex) in groups"
:key="group.label"
>
<component :is="item.icon" class="w-[18px] h-[18px]" :stroke-width="1.5" />
<!-- Tooltip -->
<div class="absolute left-full ml-2 px-2 py-1 bg-bg-elevated border border-border-subtle rounded-lg text-[0.6875rem] text-text-primary whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity duration-150 z-50">
<div class="absolute -left-1 top-1/2 -translate-y-1/2 w-0 h-0 border-y-4 border-y-transparent border-r-4" style="border-right-color: var(--color-bg-elevated)"></div>
{{ item.name }}
<!-- Section header (expanded only) -->
<div
class="overflow-hidden transition-[max-height,opacity] duration-200 ease-out"
:class="expanded ? 'max-h-8 opacity-100' : 'max-h-0 opacity-0'"
>
<p
class="px-3 pt-3 pb-1 text-[0.5625rem] text-text-tertiary uppercase tracking-[0.08em] font-medium truncate"
aria-hidden="true"
>
{{ group.label }}
</p>
</div>
</button>
<!-- Divider (collapsed only, between groups) -->
<div
class="mx-2.5 border-t border-border-subtle overflow-hidden transition-[max-height,opacity,margin] duration-200 ease-out"
:class="!expanded && groupIndex > 0 ? 'max-h-2 opacity-100 my-1' : 'max-h-0 opacity-0 my-0'"
aria-hidden="true"
/>
<!-- Items -->
<button
v-for="item in group.items"
:key="item.path"
v-tooltip.right="tip(item.name)"
@click="navigate(item.path)"
class="nav-item relative w-full flex items-center h-11 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
:class="currentPath === item.path
? 'text-text-primary'
: 'text-text-tertiary hover:text-text-secondary'"
:aria-label="item.name"
:aria-current="currentPath === item.path ? 'page' : undefined"
>
<!-- Active indicator -->
<div
class="absolute left-0 top-1 bottom-1 w-[2px] rounded-full transition-opacity duration-200"
:class="currentPath === item.path ? 'bg-accent opacity-100' : 'opacity-0'"
aria-hidden="true"
/>
<!-- Fixed-width icon container - always centered in 48px -->
<div class="relative w-12 flex items-center justify-center shrink-0">
<component :is="item.icon" aria-hidden="true" class="w-[18px] h-[18px]" :stroke-width="1.5" />
<span
v-if="item.path === '/invoices' && invoicesStore.overdueCount > 0"
class="absolute top-[-2px] right-1.5 min-w-[1rem] h-4 flex items-center justify-center text-[0.5625rem] font-bold text-white bg-status-error rounded-full px-1"
:aria-label="`${invoicesStore.overdueCount} overdue invoices`"
>
{{ invoicesStore.overdueCount }}
</span>
</div>
<!-- Label (fades in with expand) -->
<span
class="text-[0.75rem] truncate whitespace-nowrap transition-opacity duration-200"
:class="expanded ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
{{ item.name }}
</span>
</button>
</div>
</div>
<!-- Timer status indicator (bottom) -->
<div class="pb-4">
<!-- Bottom section: Settings + toggle + timer -->
<div class="flex flex-col pb-1.5 border-t border-border-subtle">
<!-- Settings -->
<button
v-tooltip.right="tip(settingsItem.name)"
@click="navigate(settingsItem.path)"
class="nav-item relative w-full flex items-center h-11 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
:class="currentPath === settingsItem.path
? 'text-text-primary'
: 'text-text-tertiary hover:text-text-secondary'"
:aria-label="settingsItem.name"
:aria-current="currentPath === settingsItem.path ? 'page' : undefined"
>
<!-- Active indicator -->
<div
class="absolute left-0 top-1 bottom-1 w-[2px] rounded-full transition-opacity duration-200"
:class="currentPath === settingsItem.path ? 'bg-accent opacity-100' : 'opacity-0'"
aria-hidden="true"
/>
<div class="w-12 flex items-center justify-center shrink-0">
<component :is="settingsItem.icon" aria-hidden="true" class="w-[18px] h-[18px]" :stroke-width="1.5" />
</div>
<span
class="text-[0.75rem] truncate whitespace-nowrap transition-opacity duration-200"
:class="expanded ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
{{ settingsItem.name }}
</span>
</button>
<!-- Expand/collapse toggle -->
<button
v-tooltip.right="tip(expanded ? 'Collapse' : 'Expand')"
@click="toggleExpanded"
class="relative w-full flex items-center h-11 transition-colors duration-150 text-text-tertiary hover:text-text-secondary focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
:aria-label="expanded ? 'Collapse navigation' : 'Expand navigation'"
:aria-expanded="expanded"
>
<div class="w-12 flex items-center justify-center shrink-0">
<component
:is="expanded ? PanelLeftClose : PanelLeftOpen"
aria-hidden="true"
class="w-[16px] h-[16px]"
:stroke-width="1.5"
/>
</div>
<span
class="text-[0.6875rem] truncate whitespace-nowrap transition-opacity duration-200"
:class="expanded ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
Collapse
</span>
</button>
<!-- Timer status indicator (only when running/paused) -->
<div
v-if="timerStore.isRunning"
class="w-2 h-2 rounded-full bg-status-running animate-pulse-dot"
/>
<div
v-else-if="timerStore.isPaused"
class="w-2 h-2 rounded-full bg-status-warning animate-pulse-dot"
/>
v-if="timerStore.isRunning || timerStore.isPaused"
class="flex items-center h-7"
role="status"
>
<span class="sr-only">
{{ timerStore.isRunning ? 'Timer running' : 'Timer paused' }}
</span>
<div class="w-12 flex items-center justify-center shrink-0">
<div
v-if="timerStore.isRunning"
class="w-2 h-2 rounded-full bg-status-running animate-pulse-dot"
aria-hidden="true"
/>
<div
v-else
class="w-2 h-2 rounded-full bg-status-warning animate-pulse-dot"
aria-hidden="true"
/>
</div>
<span
class="text-[0.6875rem] truncate whitespace-nowrap transition-opacity duration-200"
:class="[
expanded ? 'opacity-100' : 'opacity-0',
timerStore.isRunning ? 'text-status-running' : 'text-status-warning'
]"
>
{{ timerStore.isRunning ? 'Running' : 'Paused' }}
</span>
</div>
</div>
</nav>
</template>

View File

@@ -4,6 +4,7 @@ import { useFocusTrap } from '../utils/focusTrap'
import { useAnnouncer } from '../composables/useAnnouncer'
import AppSelect from './AppSelect.vue'
import AppDatePicker from './AppDatePicker.vue'
import AppTimePicker from './AppTimePicker.vue'
import { useProjectsStore, type Task } from '../stores/projects'
import { useEntriesStore } from '../stores/entries'
import { useSettingsStore } from '../stores/settings'
@@ -31,14 +32,15 @@ const selectedProjectId = ref<number | null>(null)
const selectedTaskId = ref<number | null>(null)
const description = ref('')
const entryDate = ref(new Date().toISOString().split('T')[0])
const durationInput = ref('')
const durationHour = ref(1)
const durationMinute = ref(0)
const billable = ref(1)
const tasks = ref<Task[]>([])
const saving = ref(false)
const availableProjects = computed(() => projectsStore.projects.filter(p => !p.archived))
const availableProjects = computed(() => projectsStore.activeProjects)
const canSave = computed(() => !!selectedProjectId.value && !!durationInput.value.trim() && !saving.value)
const canSave = computed(() => !!selectedProjectId.value && (durationHour.value > 0 || durationMinute.value > 0) && !saving.value)
watch(selectedProjectId, async (projectId) => {
selectedTaskId.value = null
@@ -62,7 +64,8 @@ watch(() => props.show, async (val) => {
selectedTaskId.value = null
description.value = ''
entryDate.value = new Date().toISOString().split('T')[0]
durationInput.value = ''
durationHour.value = 1
durationMinute.value = 0
billable.value = 1
saving.value = false
@@ -78,27 +81,9 @@ watch(() => props.show, async (val) => {
onUnmounted(() => deactivateTrap())
function parseDuration(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) return null
// H:MM format
if (trimmed.includes(':')) {
const [h, m] = trimmed.split(':').map(Number)
if (isNaN(h) || isNaN(m) || h < 0 || m < 0 || m > 59) return null
return h * 3600 + m * 60
}
// Decimal hours
const num = parseFloat(trimmed)
if (isNaN(num) || num < 0 || num > 24) return null
return Math.round(num * 3600)
}
async function handleSave() {
if (!selectedProjectId.value || !durationInput.value) return
const duration = parseDuration(durationInput.value)
if (duration === null || duration <= 0) return
const duration = durationHour.value * 3600 + durationMinute.value * 60
if (!selectedProjectId.value || duration <= 0) return
saving.value = true
@@ -132,7 +117,7 @@ async function handleSave() {
</script>
<template>
<Teleport to="body">
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
@@ -154,6 +139,7 @@ async function handleSave() {
@click="$emit('close')"
class="p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close"
v-tooltip="'Close'"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
@@ -201,13 +187,11 @@ async function handleSave() {
<AppDatePicker v-model="entryDate" />
</div>
<div>
<label for="quick-entry-duration" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Duration *</label>
<input
id="quick-entry-duration"
v-model="durationInput"
type="text"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary font-mono focus:outline-none focus:border-border-visible"
placeholder="1:30 or 1.5"
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Duration *</label>
<AppTimePicker
v-model:hour="durationHour"
v-model:minute="durationMinute"
placeholder="Duration"
/>
</div>
</div>

View File

@@ -53,7 +53,7 @@ function onKeydown(e: KeyboardEvent) {
</script>
<template>
<Teleport to="body">
<Teleport to="#app">
<Transition name="modal">
<div
v-if="show"
@@ -74,6 +74,7 @@ function onKeydown(e: KeyboardEvent) {
@click="zoomOut"
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Zoom out"
v-tooltip="'Zoom out'"
>
<ZoomOut class="w-4 h-4" aria-hidden="true" />
</button>
@@ -84,6 +85,7 @@ function onKeydown(e: KeyboardEvent) {
@click="zoomIn"
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Zoom in"
v-tooltip="'Zoom in'"
>
<ZoomIn class="w-4 h-4" aria-hidden="true" />
</button>
@@ -91,6 +93,7 @@ function onKeydown(e: KeyboardEvent) {
@click="$emit('close')"
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close lightbox"
v-tooltip="'Close'"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>

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