chore: tidy up project structure and normalize formatting

This commit is contained in:
Your Name
2026-02-19 22:43:14 +02:00
parent 47eb1af7ab
commit 3dcbd4a888
29 changed files with 385 additions and 11624 deletions

38
.gitignore vendored
View File

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

View File

@@ -1,72 +0,0 @@
# Clients View & NavRail Reorganization Design
**Goal:** Add a Clients management view and reorder the NavRail into a logical workflow.
---
## NavRail Reorder
**Current:** Dashboard, Timer, Projects, Entries, Reports, Invoices, Settings
**New order (workflow flow):**
1. Dashboard (`LayoutDashboard`) — Overview
2. Timer (`Clock`) — Primary action
3. Clients (`Users`) — **NEW** — Set up clients first
4. Projects (`FolderKanban`) — Projects belong to clients
5. Entries (`List`) — Time entries belong to projects
6. Invoices (`FileText`) — Bill clients for entries
7. Reports (`BarChart3`) — Analytics across everything
8. Settings (`Settings`) — Utility, always last
**Files:** `src/components/NavRail.vue`, `src/router/index.ts`
---
## Clients View
### Layout
- **Header**: "Clients" title + "+ Add" button (top right)
- **Card grid**: 1-3 columns responsive (same as Projects.vue)
- Each card: name (bold), company subtitle (if set), email (tertiary)
- Hover reveals edit/delete icons (same pattern as Projects)
- **Empty state**: `Users` icon + "No clients yet" + "Create Client" CTA
### Create/Edit Dialog
Two sections in the form:
**Contact Info:**
- Name (text, required)
- Email (text, optional)
- Phone (text, optional)
- Address (textarea, optional)
**Billing Details** (collapsible section — collapsed on create, open if data exists on edit):
- Company (text) — Business name for invoice header
- Tax ID (text) — VAT/Tax number shown on invoices
- Payment Terms (text) — e.g. "Net 30", "Due on receipt"
- Notes (textarea) — Internal notes
### Delete Confirmation
Same pattern as Projects — "Delete Client" with warning text.
---
## Backend Changes
### Database Migration
Add columns to `clients` table via `ALTER TABLE ADD COLUMN` statements in `init_db`:
- `company TEXT`
- `phone TEXT`
- `tax_id TEXT`
- `payment_terms TEXT`
- `notes TEXT`
### Rust Changes
- Expand `Client` struct with new fields (all `Option<String>`)
- Update `get_clients` SELECT to include new columns
- Update `create_client` INSERT to include new columns
- Update `update_client` UPDATE to include new columns
- Update `export_data` client query to include new columns
### Store Changes
- Expand `Client` interface in `src/stores/clients.ts` with new optional fields

View File

@@ -1,680 +0,0 @@
# Clients View & NavRail Reorganization Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a Clients management view with billing details and reorder the NavRail into a logical workflow.
**Architecture:** Backend-first approach — migrate the database schema, update Rust structs/queries, expand the TypeScript store, then build the frontend view and update navigation. The Clients.vue view follows the same card-grid + dialog pattern established by Projects.vue.
**Tech Stack:** Tauri v2 (Rust + SQLite), Vue 3 Composition API, Tailwind CSS v4 with @theme tokens, Lucide icons, Pinia stores, vue-router
---
### Task 1: Database migration — add new columns to clients table
**Files:**
- Modify: `src-tauri/src/database.rs:1-118`
**Step 1: Add ALTER TABLE statements after the existing table creation**
After the `CREATE TABLE IF NOT EXISTS clients` block (line 13), add 5 `ALTER TABLE ADD COLUMN` statements. These use `ALTER TABLE ... ADD COLUMN` which is safe to re-run because SQLite will error on duplicate columns — wrap each in a closure that ignores "duplicate column" errors.
Add this code after line 13 (after the `?;` that closes the clients CREATE TABLE):
```rust
// Migrate clients table — add new columns (safe to re-run)
let migration_columns = [
"ALTER TABLE clients ADD COLUMN company TEXT",
"ALTER TABLE clients ADD COLUMN phone TEXT",
"ALTER TABLE clients ADD COLUMN tax_id TEXT",
"ALTER TABLE clients ADD COLUMN payment_terms TEXT",
"ALTER TABLE clients ADD COLUMN notes TEXT",
];
for sql in &migration_columns {
match conn.execute(sql, []) {
Ok(_) => {}
Err(e) => {
let msg = e.to_string();
if !msg.contains("duplicate column") {
return Err(e);
}
}
}
}
```
**Step 2: Build to verify migration compiles**
Run: `cd src-tauri && cargo check`
Expected: compiles with no errors
**Step 3: Commit**
```bash
git add src-tauri/src/database.rs
git commit -m "feat: add migration for new client billing columns"
```
---
### Task 2: Expand Rust Client struct and update all CRUD queries
**Files:**
- Modify: `src-tauri/src/commands.rs:6-99` (Client struct + 4 commands)
- Modify: `src-tauri/src/commands.rs:382-457` (export_data)
**Step 1: Expand the Client struct**
Replace the existing `Client` struct (lines 7-12) with:
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct Client {
pub id: Option<i64>,
pub name: String,
pub email: Option<String>,
pub address: Option<String>,
pub company: Option<String>,
pub phone: Option<String>,
pub tax_id: Option<String>,
pub payment_terms: Option<String>,
pub notes: Option<String>,
}
```
**Step 2: Update `get_clients` query (line 62)**
Replace the SELECT and row mapping:
```rust
let mut stmt = conn.prepare(
"SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes FROM clients ORDER BY name"
).map_err(|e| e.to_string())?;
let clients = stmt.query_map([], |row| {
Ok(Client {
id: Some(row.get(0)?),
name: row.get(1)?,
email: row.get(2)?,
address: row.get(3)?,
company: row.get(4)?,
phone: row.get(5)?,
tax_id: row.get(6)?,
payment_terms: row.get(7)?,
notes: row.get(8)?,
})
}).map_err(|e| e.to_string())?;
```
**Step 3: Update `create_client` INSERT (line 77-80)**
```rust
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],
).map_err(|e| e.to_string())?;
```
**Step 4: Update `update_client` UPDATE (line 87-89)**
```rust
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],
).map_err(|e| e.to_string())?;
```
**Step 5: Update `export_data` clients query (lines 386-397)**
Replace the clients block inside `export_data`:
```rust
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 rows: Vec<serde_json::Value> = stmt.query_map([], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
"name": row.get::<_, String>(1)?,
"email": row.get::<_, Option<String>>(2)?,
"address": row.get::<_, Option<String>>(3)?,
"company": row.get::<_, Option<String>>(4)?,
"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)?
}))
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
rows
};
```
**Step 6: Build to verify everything compiles**
Run: `cd src-tauri && cargo check`
Expected: compiles with no errors
**Step 7: Commit**
```bash
git add src-tauri/src/commands.rs
git commit -m "feat: expand Client struct with billing fields and update all queries"
```
---
### Task 3: Expand TypeScript Client interface in the Pinia store
**Files:**
- Modify: `src/stores/clients.ts:5-10`
**Step 1: Add new optional fields to the Client interface**
Replace the existing `Client` interface (lines 5-10) with:
```typescript
export interface Client {
id?: number
name: string
email?: string
address?: string
company?: string
phone?: string
tax_id?: string
payment_terms?: string
notes?: string
}
```
No other changes needed — the store's `createClient`, `updateClient`, `fetchClients` all pass/receive `Client` objects, so the new fields flow through automatically.
**Step 2: Verify the frontend builds**
Run: `npm run build`
Expected: builds with no errors
**Step 3: Commit**
```bash
git add src/stores/clients.ts
git commit -m "feat: expand Client interface with billing fields"
```
---
### Task 4: Add /clients route to the router
**Files:**
- Modify: `src/router/index.ts`
**Step 1: Add the clients route**
Insert the clients route between the `/timer` and `/projects` routes (between lines 16 and 17):
```typescript
{
path: '/clients',
name: 'Clients',
component: () => import('../views/Clients.vue')
},
```
The full routes array will now be: `/` (Dashboard), `/timer` (Timer), `/clients` (Clients), `/projects` (Projects), `/entries` (Entries), `/reports` (Reports), `/invoices` (Invoices), `/settings` (Settings).
**Step 2: Commit**
```bash
git add src/router/index.ts
git commit -m "feat: add /clients route"
```
---
### Task 5: Reorder NavRail and add Clients entry
**Files:**
- Modify: `src/components/NavRail.vue`
**Step 1: Add `Users` to the Lucide import**
Replace the import block (lines 6-13) with:
```typescript
import {
LayoutDashboard,
Clock,
Users,
FolderKanban,
List,
BarChart3,
FileText,
Settings
} from 'lucide-vue-next'
```
**Step 2: Reorder the navItems array**
Replace the `navItems` array (lines 19-27) with the new workflow order:
```typescript
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: 'Invoices', path: '/invoices', icon: FileText },
{ name: 'Reports', path: '/reports', icon: BarChart3 },
{ name: 'Settings', path: '/settings', icon: Settings }
]
```
Note: Invoices moved before Reports (bill clients, then analyze).
**Step 3: Verify the frontend builds**
Run: `npm run build`
Expected: builds with no errors (will warn about missing Clients.vue — that's fine, it's lazy-loaded)
**Step 4: Commit**
```bash
git add src/components/NavRail.vue
git commit -m "feat: reorder NavRail and add Clients entry"
```
---
### Task 6: Create Clients.vue — card grid, empty state, and base layout
**Files:**
- Create: `src/views/Clients.vue`
**Step 1: Create the Clients view**
Create `src/views/Clients.vue` following the Projects.vue pattern exactly. The view has:
- Header with "Clients" title and "+ Add" button
- Card grid (1-3 columns responsive) showing: name (bold), company subtitle (if set), email (tertiary)
- Hover reveals edit/delete icons (same SVG icons as Projects.vue)
- Empty state with `Users` icon, "No clients yet" message, and "Create Client" CTA
- Create/Edit dialog with two sections:
- **Contact Info**: Name (required), Email, Phone, Address (textarea)
- **Billing Details** (collapsible): Company, Tax ID, Payment Terms, Notes (textarea)
- Delete confirmation dialog (same pattern as Projects)
```vue
<template>
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary">Clients</h1>
<button
@click="openCreateDialog"
class="px-3 py-1.5 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
+ Add
</button>
</div>
<!-- Clients Grid -->
<div v-if="clientsStore.clients.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="client in clientsStore.clients"
:key="client.id"
class="group bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] hover:bg-bg-elevated transition-all duration-150 cursor-pointer"
@click="openEditDialog(client)"
>
<div class="p-4">
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<h3 class="text-[0.8125rem] font-semibold text-text-primary truncate">{{ client.name }}</h3>
<p v-if="client.company" class="text-xs text-text-secondary mt-0.5 truncate">{{ client.company }}</p>
<p v-if="client.email" class="text-xs text-text-tertiary mt-0.5 truncate">{{ client.email }}</p>
</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-100 shrink-0 ml-2">
<button
@click.stop="openEditDialog(client)"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors duration-150"
title="Edit"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
@click.stop="confirmDelete(client)"
class="p-1.5 text-text-tertiary hover:text-status-error transition-colors duration-150"
title="Delete"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex flex-col items-center justify-center py-16">
<Users class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
<p class="text-sm text-text-secondary mt-4">No clients yet</p>
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Clients let you organize projects and generate invoices with billing details.</p>
<button
@click="openCreateDialog"
class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors"
>
Create Client
</button>
</div>
<!-- Create/Edit Dialog -->
<div
v-if="showDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center z-50"
@click.self="closeDialog"
>
<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-md mx-4 p-6 animate-modal-enter max-h-[85vh] overflow-y-auto">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
{{ editingClient ? 'Edit Client' : 'Create Client' }}
</h2>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Contact Info -->
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Name *</label>
<input
v-model="formData.name"
type="text"
required
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
placeholder="Client name"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Email</label>
<input
v-model="formData.email"
type="email"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
placeholder="client@example.com"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Phone</label>
<input
v-model="formData.phone"
type="tel"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
placeholder="+1 (555) 000-0000"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Address</label>
<textarea
v-model="formData.address"
rows="2"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible resize-none"
placeholder="Street, City, State, ZIP"
/>
</div>
<!-- Billing Details (collapsible) -->
<div class="border-t border-border-subtle pt-4">
<button
type="button"
@click="billingOpen = !billingOpen"
class="flex items-center gap-2 text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] cursor-pointer hover:text-text-secondary transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3 transition-transform duration-150"
:class="{ 'rotate-90': billingOpen }"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
Billing Details
</button>
<div v-if="billingOpen" class="mt-3 space-y-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Company</label>
<input
v-model="formData.company"
type="text"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
placeholder="Business name for invoices"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Tax ID</label>
<input
v-model="formData.tax_id"
type="text"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
placeholder="VAT / Tax number"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Payment Terms</label>
<input
v-model="formData.payment_terms"
type="text"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
placeholder="e.g. Net 30, Due on receipt"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Notes</label>
<textarea
v-model="formData.notes"
rows="2"
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible resize-none"
placeholder="Internal notes about this client"
/>
</div>
</div>
</div>
<!-- Buttons -->
<div class="flex justify-end gap-3 pt-4">
<button
type="button"
@click="closeDialog"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 bg-accent text-bg-base font-medium rounded-lg hover:bg-accent-hover transition-colors duration-150"
>
{{ editingClient ? 'Update' : 'Create' }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<div
v-if="showDeleteDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center z-50"
@click.self="cancelDelete"
>
<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 mx-4 p-6 animate-modal-enter">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Delete Client</h2>
<p class="text-[0.75rem] text-text-secondary mb-6">
Are you sure you want to delete "{{ clientToDelete?.name }}"? This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button
@click="cancelDelete"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
@click="handleDelete"
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"
>
Delete
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { Users } from 'lucide-vue-next'
import { useClientsStore, type Client } from '../stores/clients'
const clientsStore = useClientsStore()
// Dialog state
const showDialog = ref(false)
const showDeleteDialog = ref(false)
const editingClient = ref<Client | null>(null)
const clientToDelete = ref<Client | null>(null)
const billingOpen = ref(false)
// Form data
const formData = reactive<Client>({
name: '',
email: undefined,
phone: undefined,
address: undefined,
company: undefined,
tax_id: undefined,
payment_terms: undefined,
notes: undefined,
})
// Check if any billing field has data
function hasBillingData(client: Client): boolean {
return !!(client.company || client.tax_id || client.payment_terms || client.notes)
}
// Open create dialog
function openCreateDialog() {
editingClient.value = null
formData.name = ''
formData.email = undefined
formData.phone = undefined
formData.address = undefined
formData.company = undefined
formData.tax_id = undefined
formData.payment_terms = undefined
formData.notes = undefined
billingOpen.value = false
showDialog.value = true
}
// Open edit dialog
function openEditDialog(client: Client) {
editingClient.value = client
formData.id = client.id
formData.name = client.name
formData.email = client.email
formData.phone = client.phone
formData.address = client.address
formData.company = client.company
formData.tax_id = client.tax_id
formData.payment_terms = client.payment_terms
formData.notes = client.notes
billingOpen.value = hasBillingData(client)
showDialog.value = true
}
// Close dialog
function closeDialog() {
showDialog.value = false
editingClient.value = null
}
// Handle form submit
async function handleSubmit() {
if (editingClient.value) {
await clientsStore.updateClient({ ...formData })
} else {
await clientsStore.createClient({ ...formData })
}
closeDialog()
}
// Confirm delete
function confirmDelete(client: Client) {
clientToDelete.value = client
showDeleteDialog.value = true
}
// Cancel delete
function cancelDelete() {
showDeleteDialog.value = false
clientToDelete.value = null
}
// Handle delete
async function handleDelete() {
if (clientToDelete.value?.id) {
await clientsStore.deleteClient(clientToDelete.value.id)
}
cancelDelete()
}
// Load data on mount
onMounted(async () => {
await clientsStore.fetchClients()
})
</script>
```
**Step 2: Verify the full app builds**
Run: `npm run build`
Expected: builds with no errors
**Step 3: Commit**
```bash
git add src/views/Clients.vue
git commit -m "feat: add Clients view with card grid, create/edit dialog, and billing details"
```
---
### Task 7: Build and verify end-to-end
**Step 1: Full Tauri build check**
Run: `cd src-tauri && cargo check`
Expected: compiles with no errors
Run: `cd .. && npm run build`
Expected: builds with no errors
**Step 2: Verify no regressions**
Grep for any remaining references to the old nav order or missing imports:
Run: `grep -r "Users" src/components/NavRail.vue` — should find the Lucide import
Run: `grep -r "clients" src/router/index.ts` — should find the route
**Step 3: Commit all together (if any fixups needed)**
```bash
git add -A
git commit -m "feat: complete Clients view and NavRail reorganization"
```

View File

@@ -1,107 +0,0 @@
# Custom Dropdowns & Date Pickers Design
**Goal:** Replace all native `<select>` and `<input type="date">` elements with custom Vue 3 components that match ZeroClock's dark UI.
**Architecture:** Two reusable components (`AppSelect`, `AppDatePicker`) using `<Teleport to="body">` for overflow-safe positioning. Drop-in replacements — no store or logic changes needed.
---
## Inventory
### Native `<select>` (6 instances)
1. **Timer.vue** — Project selector (`selectedProject`, nullable, disabled when running)
2. **Timer.vue** — Task selector (`selectedTask`, nullable, disabled when running or no project)
3. **Projects.vue** — Client selector in dialog (`formData.client_id`, optional "No client")
4. **Entries.vue** — Project filter (`filterProject`, nullable "All Projects")
5. **Entries.vue** — Project selector in edit dialog (`editForm.project_id`, required)
6. **Invoices.vue** — Client selector in create form (`createForm.client_id`, required)
### Native `<input type="date">` (6 instances)
1. **Entries.vue** — Start Date filter
2. **Entries.vue** — End Date filter
3. **Reports.vue** — Start Date
4. **Reports.vue** — End Date
5. **Invoices.vue** — Invoice Date (required)
6. **Invoices.vue** — Due Date (optional)
---
## Component 1: AppSelect
### Trigger
- Styled like existing inputs: `bg-bg-inset`, `border-border-subtle`, `rounded-xl`
- Shows selected label or placeholder in `text-text-tertiary`
- `ChevronDown` icon (Lucide) on right, rotates 180deg when open
- Disabled state: `opacity-40`, `cursor-not-allowed`
### Dropdown Panel
- Teleported to `<body>`, positioned via `getBoundingClientRect()`
- `bg-bg-surface`, `border border-border-visible`, `rounded-xl`, shadow
- Max-height with overflow-y scroll
- Options: hover `bg-bg-elevated`, selected item shows `Check` icon in accent color
- Animate in: scale + fade (150ms)
### API
```vue
<AppSelect
v-model="selectedProject"
:options="activeProjects"
label-key="name"
value-key="id"
placeholder="Select project"
:disabled="timerStore.isRunning"
/>
```
### Behavior
- Click trigger toggles open/close
- Click option selects and closes
- Click outside closes
- Keyboard: Arrow keys navigate, Enter selects, Escape closes
- Tab moves focus away and closes
---
## Component 2: AppDatePicker
### Trigger
- Text input showing formatted date ("Feb 17, 2026") or placeholder
- `Calendar` icon (Lucide) on right
- Same input styling as AppSelect trigger
### Calendar Popover
- Teleported to `<body>`, positioned below trigger
- Header: `ChevronLeft`/`ChevronRight` arrows, center "Month Year" label
- 7-column grid: Mon-Sun header row, day cells
- Prev/next month padding days in `text-text-tertiary`
- Today: ring/border in amber accent
- Selected: solid amber accent background, white text
- Hover: `bg-bg-elevated`
- Click day: selects, closes popover, updates model
### API
```vue
<AppDatePicker
v-model="startDate"
placeholder="Start date"
:required="true"
/>
```
### Behavior
- v-model is `YYYY-MM-DD` string (same format as native date input)
- Click trigger opens calendar
- Click outside closes
- Keyboard: Arrow keys navigate days, Enter selects, Escape closes
- Month navigation via chevron buttons
---
## Integration
Pure visual swap — replace each native element with the custom component, keep the same `v-model` binding. No store or routing changes.
**Files created:** `src/components/AppSelect.vue`, `src/components/AppDatePicker.vue`
**Files modified:** Timer.vue, Projects.vue, Entries.vue, Invoices.vue, Reports.vue
The `datetime-local` input in Entries.vue edit dialog stays as-is (not in scope).

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,69 +0,0 @@
# Settings Sidebar Tabs Design
**Goal:** Replace the single-column scrolling Settings view with a left-sidebar tabbed layout that fills the available horizontal space and scales for future settings growth.
**Rationale:** The current `max-w-xl` constrained column leaves empty space to the right. A sidebar layout uses the full width, follows the macOS System Settings pattern (Apple HIG), and accommodates future settings categories without crowding.
---
## Layout
Two-panel layout: fixed-width sidebar on the left, flexible content pane on the right.
```
+------------------+---------------------------------------------+
| Settings (h1) | |
| | [Section Title] (h2) |
| > General * | |
| Timer | Setting row .................. [control] |
| Billing | Setting row .................. [control] |
| Data | |
| | |
+------------------+---------------------------------------------+
~180px remaining width
```
- Sidebar: fixed `w-44` (176px), `bg-bg-surface` background, right border `border-border-subtle`
- Content pane: fills remaining width, padded `p-6`
- No outer `max-w-xl` constraint
## Sidebar Items
Each item has a Lucide icon + text label. Order follows Apple HIG (general first, data/danger last).
| Tab | Lucide Icon | Contents |
|-----|-------------|----------|
| **General** | `Settings` | UI Scale (zoom stepper) |
| **Timer** | `Clock` | Idle Detection toggle, Reminder Interval (progressive disclosure) |
| **Billing** | `Receipt` | Default Hourly Rate |
| **Data** | `Database` | Export All Data, Clear All Data (danger zone) |
## Active State
- Active sidebar item: `bg-bg-elevated` background, `text-text-primary`, amber left border (2px `border-accent`, matching NavRail indicator)
- Inactive items: `text-text-secondary`, `hover:bg-bg-elevated` hover state
- Icons: 16x16, stroke-width 1.5
## Content Pane
- Section title (`h2`): matches active tab name, `text-[1.125rem] font-medium text-text-primary mb-6`
- Setting rows: `flex items-center justify-between` with vertical spacing
- Card-style grouping removed (no `bg-bg-surface` cards inside content pane since the pane itself is the content area)
- Danger zone item (Clear All Data) retains red border treatment within the Data tab
## Behavior
- **Auto-save**: all changes save immediately on input change (no Save button)
- **Progressive disclosure**: Reminder Interval only visible when Idle Detection is on
- **Default tab**: General (first tab active on mount)
- **State**: `activeTab` ref tracks which tab is selected, simple `v-if` switching
- **Confirmation dialog**: Clear All Data dialog unchanged
## Design Tokens Used
- `bg-bg-surface` — sidebar background
- `bg-bg-elevated` — active/hover sidebar item
- `border-border-subtle` — sidebar right border
- `border-accent` — active indicator (amber)
- `text-text-primary` / `text-text-secondary` — item text states
- `status-error` — danger zone styling

View File

@@ -1,122 +0,0 @@
# UI Improvements Batch Design
**Goal:** Five improvements — locale-aware formatting (worldwide), custom datetime picker, modal margin fix, default hourly rate bug fix, and custom number input component.
---
## 1. Locale-Aware Formatting (Worldwide)
### New Settings (General tab)
**Locale** — searchable dropdown with ALL locales the browser supports. Use `Intl.supportedValuesOf('collation')` pattern to enumerate, or provide a hardcoded comprehensive list of ~100+ BCP 47 locale tags with display names. Group by region. Include a "System Default" option that reads `navigator.language`. Stored as `locale` setting.
**Currency** — searchable dropdown with ALL ISO 4217 currency codes. Use `Intl.supportedValuesOf('currency')` to get the full list (~160 currencies). Display as "USD — US Dollar", "EUR — Euro", etc. using `Intl.DisplayNames` for native names. Stored as `currency` setting. Default: detect from locale or fall back to USD.
### Searchable AppSelect
The existing `AppSelect` dropdown can't handle 160+ items. Add a **search/filter** input at the top of the dropdown panel. When the user types, filter options by label match. This is a modification to `AppSelect.vue` — add an optional `searchable` prop.
### Locale Helpers
Create `src/utils/locale.ts` with formatting functions that read from the settings store:
```typescript
// Uses Intl APIs with the user's chosen locale + currency
formatDate(dateString: string): string // "Feb 17, 2026" or "17 Feb 2026" etc.
formatDateTime(dateString: string): string // includes time
formatCurrency(amount: number): string // "$50.00" or "50,00 €" etc.
formatNumber(amount: number, decimals?): string // "1,234.56" or "1.234,56"
```
### Files to Update (replace hardcoded `en-US` and `$`)
- **Entries.vue** — `formatDate()` function (line ~219)
- **Invoices.vue** — `formatDate()` function, all `${{ }}` currency displays (~8 occurrences)
- **Reports.vue** — `${{ }}` currency in summary stats and breakdown (~4 occurrences)
- **Timer.vue** — `formatDate()` function
- **Projects.vue** — `${{ project.hourly_rate.toFixed(2) }}/hr` display
- **Dashboard.vue** — any date/currency displays
- **AppDatePicker.vue** — month/day labels use `en-US`, should use locale
---
## 2. Custom DateTime Picker for Edit Entry
Replace `<input type="datetime-local">` in Entries.vue edit dialog with a composite layout:
```
[AppDatePicker for date] [HH] : [MM]
```
- Reuse existing `AppDatePicker` for the date portion
- Two small styled `<input>` fields for hour (0-23) and minute (0-59), separated by a `:` label
- Parse `editForm.start_time` into separate date and time parts on dialog open
- Reconstruct ISO string on submit
No new component needed — just inline the date picker + two inputs in Entries.vue.
---
## 3. Modal Viewport Margin
**Problem:** Clients.vue dialog has `max-h-[85vh]` but no guaranteed margin from viewport edges when window is very small.
**Fix:** On ALL modal dialogs across the app, ensure the overlay container uses `p-4` (16px padding on all sides) so the modal can never touch the viewport edge. Change from `flex items-center justify-center` to `flex items-center justify-center p-4` on the overlay `div`. The modal itself keeps `max-h-[calc(100vh-2rem)]` with `overflow-y-auto`.
**Files:** Clients.vue, Projects.vue, Entries.vue (2 dialogs), Invoices.vue (2 dialogs), Settings.vue (1 dialog) — every `fixed inset-0` modal overlay.
---
## 4. Default Hourly Rate Bug Fix
**Problem:** `Projects.vue` `openCreateDialog()` sets `formData.hourly_rate = 0` instead of reading the default from settings.
**Fix:** Import `useSettingsStore`, read `parseFloat(settingsStore.settings.hourly_rate) || 0` in `openCreateDialog()`. Also need to ensure settings are fetched on mount (add to the `Promise.all`).
---
## 5. AppNumberInput Component
### Design
A reusable `src/components/AppNumberInput.vue`:
```
[ - ] [ value display ] [ + ]
```
- `Minus` / `Plus` buttons styled like the UI Scale buttons in Settings (bordered, rounded-lg, icon)
- Center shows formatted value with optional prefix/suffix
- Direct text editing: clicking the value makes it editable (input field)
### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `modelValue` | `number` | required | v-model binding |
| `min` | `number` | `0` | Minimum value |
| `max` | `number` | `Infinity` | Maximum value |
| `step` | `number` | `1` | Increment/decrement amount |
| `precision` | `number` | `0` | Decimal places for display |
| `prefix` | `string` | `''` | Shown before value (e.g. "$") |
| `suffix` | `string` | `''` | Shown after value (e.g. "%", "min") |
### Press-and-Hold Behavior
On `mousedown` of +/- button:
1. Immediately fire one increment/decrement
2. After 400ms delay, start repeating every 80ms
3. On `mouseup` or `mouseleave`, clear all timers
4. Also handle `touchstart`/`touchend` for touch devices
### Locations to Replace
| View | Field | Props |
|------|-------|-------|
| Settings.vue | Default Hourly Rate | `prefix="$"` `step=1` `min=0` `precision=2` |
| Projects.vue | Hourly Rate (create/edit) | `prefix="$"` `step=1` `min=0` `precision=2` |
| Entries.vue | Duration (edit dialog) | `suffix="min"` `step=1` `min=1` |
| Invoices.vue | Tax Rate (create form) | `suffix="%"` `step=0.5` `min=0` `max=100` `precision=2` |
| Invoices.vue | Discount (create form) | `prefix="$"` `step=1` `min=0` `precision=2` |
**NOT replaced:** Reminder Interval in Settings (stays as plain input per user request).

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,239 +0,0 @@
# Invoice Templates Design
## Goal
Add 15 visually distinct invoice templates to ZeroClock with a template picker that shows live previews, replacing the current single hardcoded layout.
## Architecture
Template Config Objects approach: each template is a config object describing colors, layout, typography, and decorative elements. A single shared renderer reads the config and produces both the Vue preview (HTML/CSS) and jsPDF output. This keeps code DRY — one renderer, 15 configs.
## Tech Stack
- jsPDF for PDF generation (already installed)
- Vue 3 Composition API for preview renderer
- Tailwind CSS v4 for template picker UI
- No new dependencies required
---
## Template Config Schema
```ts
interface InvoiceTemplateConfig {
id: string
name: string
category: 'essential' | 'creative' | 'warm' | 'premium'
description: string
// Colors
colors: {
primary: string // Main accent color
secondary: string // Secondary accent
background: string // Page background
headerBg: string // Header area background
headerText: string // Header text color
bodyText: string // Body text color
tableHeaderBg: string
tableHeaderText: string
tableRowAlt: string // Alternating row color
tableBorder: string
totalHighlight: string
}
// Layout
layout: {
logoPosition: 'top-left' | 'top-center' | 'top-right'
headerStyle: 'full-width' | 'split' | 'minimal' | 'sidebar' | 'gradient' | 'geometric' | 'centered'
tableStyle: 'bordered' | 'striped' | 'borderless' | 'minimal-lines' | 'colored-sections'
totalsPosition: 'right' | 'center'
showDividers: boolean
dividerStyle: 'thin' | 'double' | 'thick' | 'none'
}
// Typography
typography: {
titleSize: number // Invoice title font size
titleWeight: 'bold' | 'normal'
headerSize: number // Section header font size
bodySize: number // Body text font size
numberStyle: 'normal' | 'monospace-feel' // For amounts
}
// Decorative
decorative: {
cornerShape: 'none' | 'colored-block' | 'triangle' | 'diagonal'
sidebarWidth: number // 0 for none
sidebarColor: string
useGradientHeader: boolean
gradientFrom?: string
gradientTo?: string
backgroundTint: boolean
backgroundTintColor?: string
}
}
```
## 15 Templates
### Tier 1: Professional Essentials
**1. Clean Minimal**
- White background, single accent line (#3B82F6 blue) under header
- Left-aligned logo, right-aligned invoice details
- Thin 1px borders, generous whitespace
- Inspired by Stripe invoices, Swiss design principles
**2. Corporate Classic**
- Navy (#1E3A5F) header band with white text
- Two-column header: business left, invoice meta right
- Gray alternating rows, bold column headers
- Inspired by QuickBooks Professional template
**3. Modern Bold**
- Large colored accent block top-left (#6366F1 indigo)
- Oversized invoice number, bold typography
- Borderless table with colored header row
- Inspired by Invoice Ninja Bold template
**4. Elegant Serif**
- Charcoal (#374151) with gold (#B8860B) accent lines
- Centered header layout, refined spacing
- Thin-rule dividers between sections
- Inspired by luxury invoicing trends
**5. Simple Two-Tone**
- Split header: dark left (#1F2937), light right
- Single-line table dividers only
- Accent (#10B981 emerald) only on totals
- Inspired by FreshBooks Modern template
### Tier 2: Creative & Modern
**6. Gradient Header**
- Full-width gradient header (#667EEA to #764BA2)
- White text on gradient for business name
- Striped table rows, generous padding
- Inspired by 2026 gradient design trends
**7. Sidebar Accent**
- Narrow (#E11D48 rose) vertical bar left edge, full page height
- Business info offset from sidebar
- Modern asymmetric layout
- Inspired by Invoice Ninja Creative template
**8. Geometric Modern**
- Colored triangle shape top-right corner (#8B5CF6 violet)
- Diagonal line element in header area
- Contemporary, design-forward
- Inspired by Dribbble geometric invoice designs
**9. Dark Mode**
- Near-black (#1A1A2E) background
- Light text (#E2E8F0), cyan (#06B6D4) highlights
- Inverted table (dark rows, light text)
- Strong 2025-2026 trend
### Tier 3: Warm & Distinctive
**10. Warm Natural**
- Warm beige (#FDF6EC) page background
- Terracotta (#C2703E) accents, olive secondary
- Soft, organic, approachable feel
**11. Playful Color Block**
- Teal (#0D9488) and coral (#F97316) blocks
- Colored backgrounds for different sections
- Fun but professional balance
- Inspired by Invoice Ninja Playful template
**12. Retro Professional**
- Double-rule lines, classic bordered table
- Warm brown (#78350F) monochrome palette
- Vintage typography hierarchy
- Inspired by Invoice Ninja Hipster template
### Tier 4: Premium & Specialized
**13. Tech Minimal**
- Slate (#475569) with electric green (#22C55E) accents
- Ultra-clean grid, large numbers
- Code-editor-inspired aesthetic
- Inspired by Invoice Ninja Tech template
**14. Executive Premium**
- Black (#111827) and gold (#D4AF37) scheme
- Generous margins, premium spacing
- Luxury feel, restrained decoration
**15. Data-Driven Clean**
- Deep blue (#1E40AF) primary
- Large prominent total amount display
- Emphasis on data hierarchy
- KPI-style layout for amounts
## Template Picker UI
### Location
Dropdown/selector in the invoice create form and in the preview dialog header.
### Layout
Split-pane design:
- **Left panel** (30%): Scrollable list of template names grouped by category, with small color dot indicators
- **Right panel** (70%): Live preview of selected template using sample/current invoice data
- Clicking a template name immediately updates the preview
### Category Headers
Templates grouped under: "Professional Essentials", "Creative & Modern", "Warm & Distinctive", "Premium & Specialized"
### Preview Rendering
- Preview uses the same renderer that generates the PDF, but outputs to an HTML canvas/div
- Shows a scaled-down A4 page with actual template styling
- Uses sample data (or current invoice data if available)
## Business Identity
### Settings Fields (already partially exist)
- `business_name` - Company/freelancer name
- `business_address` - Full address
- `business_email` - Contact email
- `business_phone` - Contact phone
- `business_logo` - Base64-encoded logo image (PNG/JPG)
### Logo Handling
- Upload via Settings > Business tab
- Stored as base64 in settings DB
- Rendered in PDF via `doc.addImage()`
- Max size: 200x80px (auto-scaled)
- Position determined by template config
## Shared Renderer Architecture
```
InvoiceTemplateConfig + InvoiceData + ClientData + Items
Shared Renderer Logic
↓ ↓
Vue HTML Preview jsPDF Generator
(scaled div) (actual PDF)
```
### Implementation Approach
- `src/utils/invoiceTemplates.ts` — 15 template config objects + registry
- `src/utils/invoicePdfRenderer.ts` — jsPDF renderer that reads config
- `src/components/InvoicePreview.vue` — Vue component that renders HTML preview from config
- `src/components/InvoiceTemplatePicker.vue` — Split-pane picker UI
### Renderer Functions
Each layout/decorative feature maps to a render function:
- `renderHeader(doc, config, data)` — Handles all header styles
- `renderLineItems(doc, config, items)` — Handles all table styles
- `renderTotals(doc, config, totals)` — Handles totals section
- `renderDecorative(doc, config)` — Handles corners, sidebars, gradients
- `renderFooter(doc, config, notes)` — Handles notes and footer
## Verification
1. All 15 templates render correctly in both preview and PDF
2. Template picker shows all templates with live preview
3. Each template is visually distinct and professional
4. Logo renders correctly in templates that support it
5. Currency/locale formatting works across all templates
6. Long content (many line items) handles page breaks correctly
7. PDF export works via Tauri save dialog for all templates

File diff suppressed because it is too large Load Diff

View File

@@ -1,262 +0,0 @@
# Invoice Templates v2 — Complete Redesign
## Goal
Replace the current invoice template system with genuinely beautiful, typographically correct templates and a two-step UX flow where the user creates the invoice first, then picks a template on a dedicated full-screen page.
## Two Problems Being Solved
1. **UX flow**: Template picker is crammed into the create form. Should be a separate step after invoice data is entered.
2. **Template quality**: All 15 templates look the same — just color swaps of identical layout with tiny unreadable text, spreadsheet-like tables, and no visual hierarchy.
---
## Part 1: UX Flow
### Current Flow (broken)
Single "Create" tab with invoice fields, line items, AND template picker + preview all on one page.
### New Flow — Two Steps
**Step 1: Invoice Form** (the existing "Create" tab, minus template picker)
- Invoice number, client, dates, line items, notes, tax, discount, totals
- "Create Invoice" button saves to DB, then navigates to Step 2
**Step 2: Template Picker** (new full-screen view)
- Route: Invoices view with `view = 'template-picker'` state, receives `invoiceId`
- Also accessible from invoice list (clicking "View" on any existing invoice)
- Layout: narrow sidebar (template list by category with color dots) + large preview area
- Bottom bar: "Export PDF" button + "Save & Close" button
- Template choice saved to invoice via `template_id` column in DB
- Pre-selects previously chosen template when viewing existing invoices
### Data Flow
1. User fills form → "Create Invoice" → invoice + items saved → navigate to template picker with invoiceId
2. Template picker loads invoice + items from DB, renders live preview
3. User browses templates, optionally exports PDF
4. "Save & Close" → saves template_id to invoice → back to list
### Database Change
Add `template_id TEXT DEFAULT 'clean'` column to `invoices` table.
Add `update_invoice_template` Tauri command.
---
## Part 2: Template Design System
### Design Principles (from research)
1. **Total Due is the most prominent number** — 16-20pt bold, highlighted
2. **Maximum 2 font weights** per template; hierarchy through size contrast
3. **Borderless tables** preferred — subtle horizontal rules, not spreadsheet grids
4. **70-20-10 color rule** — 70% white/neutral, 20% primary, 10% secondary
5. **Body text minimum 10pt in PDF** (8pt absolute floor for fine print)
6. **Right-align monetary values**, left-align descriptions
7. **Generous whitespace** between sections (8-12mm in PDF)
8. **ALL CAPS only for short labels** with +0.05em letter-spacing
9. **Line height**: 1.5 for body text, 1.2-1.3 for headers
10. **Tabular numerals** in number columns for perfect alignment
### Typography Scale (PDF, in points)
| Element | Size | Weight | Notes |
|---|---|---|---|
| "INVOICE" title | 24-32pt | Bold | Most visually prominent text |
| Company name | 14-18pt | Semi-bold | Or use logo image |
| Section headers | 11-12pt | Semi-bold | Uppercase + tracked |
| Body text / descriptions | 10-11pt | Regular | Core readability |
| Column headers | 9-10pt | Medium | Uppercase + tracked |
| Total amount | 16-20pt | Bold | Star of the page |
| Due date | 12-14pt | Semi-bold | Second most prominent |
| Footer / notes | 8-9pt | Regular | Minimum legible |
### HTML Preview Typography (scaled for ~300px wide preview)
| Element | Size | Notes |
|---|---|---|
| "INVOICE" title | 18-24px | Bold |
| Company name | 11-14px | Semi-bold |
| Section headers | 8-9px | Uppercase, tracked |
| Body text | 7.5-8px | Regular |
| Column headers | 7-7.5px | Medium, uppercase |
| Total amount | 12-16px | Bold |
| Fine print | 6.5-7px | Regular |
### Information Architecture (top-to-bottom)
```
1. HEADER ZONE — Logo + branding + "INVOICE" title + invoice #/date/due date
2. PARTIES ZONE — From (business) left | Bill To (client) right
3. LINE ITEMS — Table: Description | Qty | Rate | Amount
4. TOTALS — Subtotal, Tax, Discount, TOTAL DUE (prominent)
5. FOOTER — Notes, payment terms, thank you
```
### Whitespace (PDF, in mm)
| Area | Value |
|---|---|
| Page margins | 20mm all sides |
| Between major sections | 8-12mm |
| Table cell padding | 3mm vertical, 4mm horizontal |
| Between label and value | 2mm |
| Logo max height | 16mm |
### Table Design Rules
- **Preferred**: Borderless with thin horizontal rules (0.3pt, light gray)
- **No vertical borders** in any template
- Column headers distinguished by weight + bottom border (1pt)
- Zebra striping: alternate between white and barely-gray (#f8fafc)
- Right-align Qty, Rate, Amount columns
- Generous row height (8-10mm)
---
## The 15 Templates
### Tier 1: Professional Essentials
**1. Clean**
- Header: Logo top-left, biz name below, "INVOICE" in slate with thin accent line (30% width)
- From/To in two columns below
- Table: Borderless, thin gray bottom borders per row, light gray header bg
- Totals: Right-aligned, total in bold slate
- Color: Monochrome slate (#1e293b) with blue accent (#3b82f6) only on the total
- Vibe: Stripe invoices. Swiss minimalism. Maximum breathing room.
**2. Professional**
- Header: Full-width navy (#1e3a5f) band across top ~50mm. White "INVOICE" large. Biz name white right side. Invoice meta in white below title.
- Below band: From/To section
- Table: Navy header row, light gray zebra stripes, no borders
- Totals: Right-aligned, navy total
- Vibe: QuickBooks polished. Corporate trust.
**3. Bold**
- Header: Large indigo (#4f46e5) rectangle top-left ~55% width × 50mm. "INVOICE" massive white inside. Invoice # white below title. Biz info outside block to the right in dark text.
- Below: "FROM" and "TO" labels (uppercase, tracked) with info in two columns
- Table: Indigo header row, no borders at all, generous 10mm row height
- Totals: Large indigo total (18pt), prominent
- Vibe: Design agency. Confident and modern.
**4. Minimal**
- Header: Everything centered. Logo centered. Thin rule. "INVOICE" centered in charcoal. Biz name centered. Thin rule.
- Below rules: From/To in two columns
- Table: No backgrounds anywhere. Whitespace-only row separation (generous padding). Header distinguished only by bold weight.
- Totals: Centered, total in dark bold
- Color: Pure monochrome charcoal (#18181b). No accent color at all.
- Vibe: Whisper-quiet. Dieter Rams.
**5. Classic**
- Header: Traditional two-column. Biz info top-left, "INVOICE" + meta top-right. Serif-inspired feel (use helvetica bold with wider tracking to suggest formality).
- Thin burgundy (#7f1d1d) rule below header
- Table: Light bordered grid (all cells), burgundy header bg with white text. Warm gray alternating rows.
- Totals: Right-aligned, burgundy total
- Vibe: Traditional accounting. Established firm.
### Tier 2: Creative & Modern
**6. Modern**
- Header: Asymmetric — "INVOICE" large at top-left. Invoice # and date directly below in smaller text. Biz info block pushed to the right side, vertically centered to the title.
- Thin teal (#0d9488) line separating header from body
- From/To below in two columns
- Table: Borderless, thin teal bottom borders per row, teal header text (not bg fill)
- Totals: Teal total with subtle teal bg strip behind total row
- Vibe: Trendy tech startup.
**7. Elegant**
- Header: Centered. Gold (#a16207) double-rule line at top (two thin lines 1.5mm apart). "INVOICE" centered below. Biz name centered below. Another gold double-rule.
- Below: From/To in two columns
- Table: No colored header bg. Gold double-rule above and below header text. Single thin gold rule between each row. Clean, refined.
- Totals: Right-aligned, gold total
- Vibe: Luxury stationery. Calligraphy studio.
**8. Creative**
- Decorative: Narrow purple (#7c3aed) sidebar on left, full page height, ~6mm wide
- Header: Content offset past sidebar. Logo near sidebar. "INVOICE" in purple.
- From/To below
- Table: Each row styled as a subtle card (very faint bg + tiny border-radius visual feel via padding). Purple-tinted header text.
- Totals: Purple total
- Vibe: Creative agency portfolio.
**9. Compact**
- Header: Single-line inline — biz name left, "INVOICE #XXX" right, all on one line. Date/due below.
- From/To: Two columns, tight spacing
- Table: Tight zebra stripes (6mm rows), no borders. Efficient use of space.
- Totals: Right-aligned, subtle
- Color: Slate (#475569) accent
- Vibe: Accountant's no-nonsense. Data-dense but clean.
### Tier 3: Warm & Distinctive
**10. Dark**
- Entire page: Near-black (#0f172a) background
- All text: Light (#e2e8f0). "INVOICE" in cyan (#06b6d4). Thin cyan accent line.
- Table: Very dark header (#020617) with cyan column names. Alternating dark rows.
- Totals: Cyan total on dark bg
- Vibe: IDE/terminal aesthetic.
**11. Vibrant**
- Header: Full-width gradient band (coral #ea580c to warm orange #f97316), ~45mm. White "INVOICE" + biz info inside.
- Below: From/To
- Table: Light warm-tinted header, borderless, light warm row tints
- Totals: Coral total
- Vibe: SaaS marketing. Warm and energetic.
**12. Corporate**
- Header: Deep blue (#1e40af) band across top ~45mm with white text. Below band: a thin lighter blue info bar with invoice meta.
- From/To below bars
- Table: Light bordered (thin gray on all cells), blue header row
- Totals: Blue total
- Vibe: Enterprise. Annual report.
### Tier 4: Premium & Specialized
**13. Fresh**
- Header: Logo left. Large invoice number right side (oversized, light sky blue text as a background watermark effect). "INVOICE" smaller above the number.
- From/To below
- Table: Sky blue (#0284c7) header bg, light blue zebra stripes
- Totals: Sky blue total
- Vibe: Startup friendly. Light and airy.
**14. Natural**
- Entire page: Warm beige (#fdf6ec) background
- "INVOICE" in terracotta (#c2703e). Biz info in warm brown.
- Table: Terracotta header, warm cream alternating rows on beige
- Totals: Terracotta total
- Vibe: Artisan workshop. Organic soap shop.
**15. Statement**
- Header: "INVOICE" normal size top-left. But the TOTAL AMOUNT is displayed massively (32pt+) in the top-right as the hero element, with "TOTAL DUE" label above it.
- Below: Standard From/To, invoice meta
- Table: Clean, whitespace-only separation. No borders, no stripes. Just generous padding.
- Totals: Already displayed prominently at top. Bottom totals section is a simple summary.
- Color: Monochrome with rose (#be123c) accent on the big total
- Vibe: "Pay me" energy. Total-forward design.
---
## Files to Change
### Modify
- `src/views/Invoices.vue` — Remove template picker from create tab, add new 'template-picker' view state, change handleCreate to navigate to picker after save
- `src/utils/invoiceTemplates.ts` — Rewrite configs with new template IDs and color palettes
- `src/components/InvoicePreview.vue` — Complete rewrite with 15 genuinely unique layouts following typography research
- `src/utils/invoicePdfRenderer.ts` — Complete rewrite with 15 matching PDF render functions using proper type scale
- `src-tauri/src/database.rs` — Add `template_id` column to invoices table
- `src-tauri/src/commands.rs` — Add `update_invoice_template` command
### Keep as-is
- `src/utils/invoicePdf.ts` — Thin wrapper, just update signature if needed
- `src/components/InvoiceTemplatePicker.vue` — The split-pane layout is fine, may adjust sizing for full-screen use
- `src/stores/invoices.ts` — May need minor update for template_id field
## Verification
1. Create an invoice → lands on template picker with actual data
2. All 15 templates render with correct typography scale and unique layouts
3. Each template is immediately visually distinguishable from the others
4. Total amount is the most prominent element in every template
5. PDF export matches the HTML preview for all templates
6. Template choice persists — reopen an invoice and it remembers
7. No text below 10pt in PDF (8pt for fine print only)
8. Tables use borderless/subtle-rule design (no spreadsheet grids)

View File

@@ -1,992 +0,0 @@
# Invoice Templates v2 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the invoice template system with 15 genuinely beautiful, typographically distinct templates and a two-step UX flow (create invoice → full-screen template picker).
**Architecture:** The invoices view gains a third state `'template-picker'` that shows a full-screen split-pane picker after invoice creation. Each of the 15 templates has its own unique HTML layout in `InvoicePreview.vue` and its own PDF render function in `invoicePdfRenderer.ts`, following researched typography scales. A new `template_id` column on the invoices table persists the user's template choice.
**Tech Stack:** Vue 3 Composition API, Tailwind CSS v4, jsPDF, Tauri v2 (Rust/rusqlite), Pinia
---
### Task 1: Add template_id column to database and Rust backend
**Files:**
- Modify: `src-tauri/src/database.rs:94-112` (invoices table area)
- Modify: `src-tauri/src/commands.rs:52-65` (Invoice struct)
- Modify: `src-tauri/src/commands.rs:317-367` (create/get/update invoice commands)
- Modify: `src-tauri/src/lib.rs:39-89` (register new command)
**Step 1: Add migration in database.rs**
After the invoices CREATE TABLE (around line 112), add a safe migration using the same pattern as clients/projects:
```rust
// Migrate invoices table — add template_id column (safe to re-run)
let invoice_migrations = [
"ALTER TABLE invoices ADD COLUMN template_id TEXT DEFAULT 'clean'",
];
for sql in &invoice_migrations {
match conn.execute(sql, []) {
Ok(_) => {}
Err(e) => {
let msg = e.to_string();
if !msg.contains("duplicate column") {
return Err(e);
}
}
}
}
```
**Step 2: Add template_id to Invoice struct in commands.rs**
Add to the `Invoice` struct (after `status` field at line 64):
```rust
pub template_id: Option<String>,
```
**Step 3: Update get_invoices to SELECT template_id**
In `get_invoices` (line 330), change the SELECT to include `template_id` at position 12:
```rust
"SELECT id, client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id
FROM invoices ORDER BY date DESC"
```
And in the row mapping, add after `status: row.get(11)?`:
```rust
template_id: row.get(12)?,
```
**Step 4: Update create_invoice to INSERT template_id**
In `create_invoice` (line 317), change the INSERT:
```rust
"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, ?11, ?12)",
params![invoice.client_id, invoice.invoice_number, invoice.date, invoice.due_date,
invoice.subtotal, invoice.tax_rate, invoice.tax_amount, invoice.discount,
invoice.total, invoice.notes, invoice.status, invoice.template_id],
```
**Step 5: Update update_invoice to SET template_id**
In `update_invoice` (line 356), change the UPDATE:
```rust
"UPDATE invoices SET client_id = ?1, invoice_number = ?2, date = ?3, due_date = ?4,
subtotal = ?5, tax_rate = ?6, tax_amount = ?7, discount = ?8, total = ?9, notes = ?10, status = ?11, template_id = ?12
WHERE id = ?13",
params![invoice.client_id, invoice.invoice_number, invoice.date, invoice.due_date,
invoice.subtotal, invoice.tax_rate, invoice.tax_amount, invoice.discount,
invoice.total, invoice.notes, invoice.status, invoice.template_id, invoice.id],
```
**Step 6: Add update_invoice_template command**
Add a new lightweight command after `delete_invoice` (after line 374):
```rust
#[tauri::command]
pub fn update_invoice_template(state: State<AppState>, id: i64, template_id: String) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute(
"UPDATE invoices SET template_id = ?1 WHERE id = ?2",
params![template_id, id],
).map_err(|e| e.to_string())?;
Ok(())
}
```
**Step 7: Register in lib.rs**
Add `commands::update_invoice_template,` after the `commands::delete_invoice,` line in the `generate_handler!` macro.
**Step 8: Verify**
Run: `cd src-tauri && cargo build`
Expected: Compiles with no errors.
**Step 9: Commit**
```bash
git add src-tauri/src/database.rs src-tauri/src/commands.rs src-tauri/src/lib.rs
git commit -m "feat: add template_id column to invoices table and update_invoice_template command"
```
---
### Task 2: Update frontend Invoice interface and store
**Files:**
- Modify: `src/stores/invoices.ts:5-18` (Invoice interface)
- Modify: `src/stores/invoices.ts` (add updateInvoiceTemplate action)
**Step 1: Add template_id to Invoice interface**
In `src/stores/invoices.ts`, add `template_id` to the `Invoice` interface (after `status: string` at line 17):
```typescript
template_id?: string
```
**Step 2: Add updateInvoiceTemplate store action**
Add after the `deleteInvoice` function (after line 79):
```typescript
async function updateInvoiceTemplate(id: number, templateId: string): Promise<boolean> {
try {
await invoke('update_invoice_template', { id, templateId })
const index = invoices.value.findIndex(i => i.id === id)
if (index !== -1) {
invoices.value[index].template_id = templateId
}
return true
} catch (error) {
console.error('Failed to update invoice template:', error)
return false
}
}
```
**Step 3: Export the new action**
Add `updateInvoiceTemplate` to the return object (around line 111-121).
**Step 4: Commit**
```bash
git add src/stores/invoices.ts
git commit -m "feat: add template_id to Invoice interface and updateInvoiceTemplate action"
```
---
### Task 3: Rewrite invoiceTemplates.ts with new IDs and design doc colors
**Files:**
- Rewrite: `src/utils/invoiceTemplates.ts`
**Step 1: Rewrite the entire file**
Replace the entire file with new template configs matching the design doc's 15 templates. The new IDs are: `clean`, `professional`, `bold`, `minimal`, `classic`, `modern`, `elegant`, `creative`, `compact`, `dark`, `vibrant`, `corporate`, `fresh`, `natural`, `statement`.
The interface stays simple — `id`, `name`, `category`, `description`, `colors` — but the color object maps to the exact hex values from the design doc for each template.
```typescript
export interface InvoiceTemplateColors {
primary: string
secondary: string
background: string
headerBg: string
headerText: string
bodyText: string
tableHeaderBg: string
tableHeaderText: string
tableRowAlt: string
tableBorder: string
totalHighlight: string
}
export interface InvoiceTemplateConfig {
id: string
name: string
category: 'essential' | 'creative' | 'warm' | 'premium'
description: string
colors: InvoiceTemplateColors
}
const clean: InvoiceTemplateConfig = {
id: 'clean',
name: 'Clean',
category: 'essential',
description: 'Swiss minimalism with a single blue accent',
colors: {
primary: '#1e293b',
secondary: '#3b82f6',
background: '#ffffff',
headerBg: '#ffffff',
headerText: '#1e293b',
bodyText: '#374151',
tableHeaderBg: '#f8fafc',
tableHeaderText: '#374151',
tableRowAlt: '#f8fafc',
tableBorder: '#e5e7eb',
totalHighlight: '#3b82f6',
},
}
const professional: InvoiceTemplateConfig = {
id: 'professional',
name: 'Professional',
category: 'essential',
description: 'Navy header band with corporate polish',
colors: {
primary: '#1e3a5f',
secondary: '#2563eb',
background: '#ffffff',
headerBg: '#1e3a5f',
headerText: '#ffffff',
bodyText: '#374151',
tableHeaderBg: '#1e3a5f',
tableHeaderText: '#ffffff',
tableRowAlt: '#f3f4f6',
tableBorder: '#d1d5db',
totalHighlight: '#1e3a5f',
},
}
const bold: InvoiceTemplateConfig = {
id: 'bold',
name: 'Bold',
category: 'essential',
description: 'Large indigo block with oversized typography',
colors: {
primary: '#4f46e5',
secondary: '#a5b4fc',
background: '#ffffff',
headerBg: '#4f46e5',
headerText: '#ffffff',
bodyText: '#1f2937',
tableHeaderBg: '#4f46e5',
tableHeaderText: '#ffffff',
tableRowAlt: '#f5f3ff',
tableBorder: '#e0e7ff',
totalHighlight: '#4f46e5',
},
}
const minimal: InvoiceTemplateConfig = {
id: 'minimal',
name: 'Minimal',
category: 'essential',
description: 'Pure monochrome centered layout',
colors: {
primary: '#18181b',
secondary: '#18181b',
background: '#ffffff',
headerBg: '#ffffff',
headerText: '#18181b',
bodyText: '#3f3f46',
tableHeaderBg: '#ffffff',
tableHeaderText: '#18181b',
tableRowAlt: '#ffffff',
tableBorder: '#e4e4e7',
totalHighlight: '#18181b',
},
}
const classic: InvoiceTemplateConfig = {
id: 'classic',
name: 'Classic',
category: 'essential',
description: 'Traditional layout with burgundy accents',
colors: {
primary: '#7f1d1d',
secondary: '#991b1b',
background: '#ffffff',
headerBg: '#7f1d1d',
headerText: '#ffffff',
bodyText: '#374151',
tableHeaderBg: '#7f1d1d',
tableHeaderText: '#ffffff',
tableRowAlt: '#f5f5f4',
tableBorder: '#d6d3d1',
totalHighlight: '#7f1d1d',
},
}
const modern: InvoiceTemplateConfig = {
id: 'modern',
name: 'Modern',
category: 'creative',
description: 'Asymmetric header with teal accents',
colors: {
primary: '#0d9488',
secondary: '#14b8a6',
background: '#ffffff',
headerBg: '#ffffff',
headerText: '#0f172a',
bodyText: '#334155',
tableHeaderBg: '#ffffff',
tableHeaderText: '#0d9488',
tableRowAlt: '#f0fdfa',
tableBorder: '#99f6e4',
totalHighlight: '#0d9488',
},
}
const elegant: InvoiceTemplateConfig = {
id: 'elegant',
name: 'Elegant',
category: 'creative',
description: 'Gold double-rule accents on centered layout',
colors: {
primary: '#a16207',
secondary: '#ca8a04',
background: '#ffffff',
headerBg: '#ffffff',
headerText: '#422006',
bodyText: '#57534e',
tableHeaderBg: '#ffffff',
tableHeaderText: '#422006',
tableRowAlt: '#fefce8',
tableBorder: '#a16207',
totalHighlight: '#a16207',
},
}
const creative: InvoiceTemplateConfig = {
id: 'creative',
name: 'Creative',
category: 'creative',
description: 'Purple sidebar with card-style rows',
colors: {
primary: '#7c3aed',
secondary: '#a78bfa',
background: '#ffffff',
headerBg: '#ffffff',
headerText: '#1f2937',
bodyText: '#374151',
tableHeaderBg: '#faf5ff',
tableHeaderText: '#7c3aed',
tableRowAlt: '#faf5ff',
tableBorder: '#e9d5ff',
totalHighlight: '#7c3aed',
},
}
const compact: InvoiceTemplateConfig = {
id: 'compact',
name: 'Compact',
category: 'creative',
description: 'Data-dense layout with tight spacing',
colors: {
primary: '#475569',
secondary: '#64748b',
background: '#ffffff',
headerBg: '#ffffff',
headerText: '#0f172a',
bodyText: '#334155',
tableHeaderBg: '#f1f5f9',
tableHeaderText: '#334155',
tableRowAlt: '#f8fafc',
tableBorder: '#e2e8f0',
totalHighlight: '#475569',
},
}
const dark: InvoiceTemplateConfig = {
id: 'dark',
name: 'Dark',
category: 'warm',
description: 'Near-black background with cyan highlights',
colors: {
primary: '#06b6d4',
secondary: '#22d3ee',
background: '#0f172a',
headerBg: '#020617',
headerText: '#e2e8f0',
bodyText: '#cbd5e1',
tableHeaderBg: '#020617',
tableHeaderText: '#06b6d4',
tableRowAlt: '#1e293b',
tableBorder: '#334155',
totalHighlight: '#06b6d4',
},
}
const vibrant: InvoiceTemplateConfig = {
id: 'vibrant',
name: 'Vibrant',
category: 'warm',
description: 'Coral-to-orange gradient header band',
colors: {
primary: '#ea580c',
secondary: '#f97316',
background: '#ffffff',
headerBg: '#ea580c',
headerText: '#ffffff',
bodyText: '#1f2937',
tableHeaderBg: '#fff7ed',
tableHeaderText: '#9a3412',
tableRowAlt: '#fff7ed',
tableBorder: '#fed7aa',
totalHighlight: '#ea580c',
},
}
const corporate: InvoiceTemplateConfig = {
id: 'corporate',
name: 'Corporate',
category: 'warm',
description: 'Deep blue header with info bar below',
colors: {
primary: '#1e40af',
secondary: '#3b82f6',
background: '#ffffff',
headerBg: '#1e40af',
headerText: '#ffffff',
bodyText: '#1f2937',
tableHeaderBg: '#1e40af',
tableHeaderText: '#ffffff',
tableRowAlt: '#eff6ff',
tableBorder: '#bfdbfe',
totalHighlight: '#1e40af',
},
}
const fresh: InvoiceTemplateConfig = {
id: 'fresh',
name: 'Fresh',
category: 'premium',
description: 'Oversized watermark invoice number',
colors: {
primary: '#0284c7',
secondary: '#38bdf8',
background: '#ffffff',
headerBg: '#ffffff',
headerText: '#0c4a6e',
bodyText: '#334155',
tableHeaderBg: '#0284c7',
tableHeaderText: '#ffffff',
tableRowAlt: '#f0f9ff',
tableBorder: '#bae6fd',
totalHighlight: '#0284c7',
},
}
const natural: InvoiceTemplateConfig = {
id: 'natural',
name: 'Natural',
category: 'premium',
description: 'Warm beige background with terracotta accents',
colors: {
primary: '#c2703e',
secondary: '#d97706',
background: '#fdf6ec',
headerBg: '#fdf6ec',
headerText: '#78350f',
bodyText: '#57534e',
tableHeaderBg: '#c2703e',
tableHeaderText: '#ffffff',
tableRowAlt: '#fef3c7',
tableBorder: '#d6d3d1',
totalHighlight: '#c2703e',
},
}
const statement: InvoiceTemplateConfig = {
id: 'statement',
name: 'Statement',
category: 'premium',
description: 'Total-forward design with hero amount',
colors: {
primary: '#18181b',
secondary: '#be123c',
background: '#ffffff',
headerBg: '#ffffff',
headerText: '#18181b',
bodyText: '#3f3f46',
tableHeaderBg: '#ffffff',
tableHeaderText: '#18181b',
tableRowAlt: '#ffffff',
tableBorder: '#e4e4e7',
totalHighlight: '#be123c',
},
}
export const INVOICE_TEMPLATES: InvoiceTemplateConfig[] = [
clean, professional, bold, minimal, classic,
modern, elegant, creative, compact,
dark, vibrant, corporate,
fresh, natural, statement,
]
export const TEMPLATE_CATEGORIES = [
{ id: 'essential', label: 'Professional Essentials' },
{ id: 'creative', label: 'Creative & Modern' },
{ id: 'warm', label: 'Warm & Distinctive' },
{ id: 'premium', label: 'Premium & Specialized' },
] as const
export function getTemplateById(id: string): InvoiceTemplateConfig {
return INVOICE_TEMPLATES.find(t => t.id === id) || INVOICE_TEMPLATES[0]
}
export function getTemplatesByCategory(category: string): InvoiceTemplateConfig[] {
return INVOICE_TEMPLATES.filter(t => t.category === category)
}
```
**Step 2: Commit**
```bash
git add src/utils/invoiceTemplates.ts
git commit -m "feat: rewrite invoice template configs with design-doc IDs and colors"
```
---
### Task 4: Rewrite InvoicePreview.vue with 15 unique HTML layouts
**Files:**
- Rewrite: `src/components/InvoicePreview.vue`
This is the largest single task. Each of the 15 templates needs a genuinely unique HTML layout following the design doc specifications. The component receives `template`, `invoice`, `client`, `items`, `businessInfo` props and renders a scaled A4 preview (~300px wide).
**Typography scale for HTML preview (from design doc):**
- "INVOICE" title: 18-24px bold
- Company name: 11-14px semi-bold
- Section headers: 8-9px uppercase, letter-spacing 0.05em
- Body text: 7.5-8px regular
- Column headers: 7-7.5px medium, uppercase
- Total amount: 12-16px bold
- Fine print: 6.5-7px regular
**Key design rules:**
- Total Due is the most prominent number in every template
- Borderless tables (thin horizontal rules, no vertical borders, no spreadsheet grids)
- Right-align monetary values, left-align descriptions
- Tables use thin bottom borders per row (border-bottom: 0.5px solid color)
- Zebra striping only where specified (alternate barely-gray #f8fafc)
- Generous whitespace between sections
**Per-template layout descriptions (from design doc):**
1. **Clean**: Logo top-left, biz name below, "INVOICE" in slate with thin accent line (30% width). Two-column From/To. Borderless table with thin gray row borders, light gray header bg. Blue accent only on total.
2. **Professional**: Full-width navy band across top ~17% height. White "INVOICE" large inside band. Biz name white right side. Navy header table row, light gray zebra stripes.
3. **Bold**: Large indigo rectangle top-left ~55% width × 17% height. "INVOICE" massive white inside. Biz info outside block to right. Indigo header row, no borders at all, generous row height.
4. **Minimal**: Everything centered. Logo centered. Thin rule. "INVOICE" centered in charcoal. No backgrounds anywhere. Whitespace-only row separation. Pure monochrome #18181b.
5. **Classic**: Traditional two-column header. Biz info left, "INVOICE" + meta right. Thin burgundy rule below. Burgundy header bg with white text. Light bordered grid. Warm gray alternating rows.
6. **Modern**: Asymmetric — "INVOICE" large top-left. Biz info pushed right. Thin teal line separator. Teal header text (no bg fill), thin teal bottom borders. Teal bg strip behind total row.
7. **Elegant**: Centered. Gold double-rule at top (two thin lines). "INVOICE" centered below. Another double-rule. Gold rules between rows. No colored header bg.
8. **Creative**: Narrow purple sidebar on left (~3% width), full height. Content offset past sidebar. Card-style rows (faint bg + padding). Purple-tinted header text.
9. **Compact**: Single-line header — biz name left, "INVOICE #XXX" right, same line. Tight zebra stripes (small row height). Efficient space usage. Slate accent.
10. **Dark**: Near-black #0f172a background. Light text #e2e8f0. "INVOICE" in cyan #06b6d4. Very dark header with cyan column names. Alternating dark rows.
11. **Vibrant**: Full-width gradient band (coral→orange) ~15% height. White text inside. Light warm-tinted table. Coral total.
12. **Corporate**: Deep blue band top ~15% with white text. Thin lighter blue info bar below. Light bordered table (thin gray on all cells). Blue header row.
13. **Fresh**: Logo left. Large invoice number right side (oversized, light sky blue as watermark). Sky blue header bg. Light blue zebra stripes.
14. **Natural**: Warm beige #fdf6ec background for entire page. "INVOICE" in terracotta #c2703e. Terracotta header. Warm cream alternating rows.
15. **Statement**: "INVOICE" normal top-left. TOTAL AMOUNT displayed massively top-right (~24px+). Whitespace-only table separation. No borders, no stripes. Rose accent on big total.
**Implementation approach:**
The template uses a `v-if`/`v-else-if` chain to switch between 15 completely different layout blocks. Each block contains its own unique HTML structure. Shared computed values (displayItems, clientName, etc.) are reused across all templates.
The script section stays lean — props, computed helpers for items/client/biz, formatCurrency/formatDate imports. All visual differentiation is in the template.
**Step 1: Write the complete component**
Write `InvoicePreview.vue` with the script setup section and all 15 template blocks. Each template block must follow its specific design doc layout. Use inline styles for template-specific colors (from `c` computed). Use Tailwind-like patterns via inline style objects.
All templates share:
- Outer `<div>` with `aspect-ratio: 210/297`, `width: 100%`, `overflow: hidden`, `font-family: -apple-system, ...sans-serif`
- Padding of roughly `6%` to simulate page margins
- The same data bindings (invoice number, dates, client info, business info, line items, totals)
**Step 2: Verify**
Run: `npm run build` (from project root)
Expected: Compiles with no errors. May have chunk size warning — that's OK.
**Step 3: Commit**
```bash
git add src/components/InvoicePreview.vue
git commit -m "feat: rewrite InvoicePreview with 15 unique typographic layouts"
```
---
### Task 5: Rewrite invoicePdfRenderer.ts with 15 unique PDF render functions
**Files:**
- Rewrite: `src/utils/invoicePdfRenderer.ts`
Each template gets its own render function that produces a unique PDF layout matching the HTML preview. Uses jsPDF directly.
**Typography scale for PDF (from design doc, in points):**
- "INVOICE" title: 24-32pt bold
- Company name: 14-18pt semi-bold
- Section headers: 11-12pt semi-bold, uppercase, letter-spacing +0.05em
- Body text: 10-11pt regular
- Column headers: 9-10pt medium, uppercase
- Total amount: 16-20pt bold
- Due date: 12-14pt semi-bold
- Footer/notes: 8-9pt regular
**PDF whitespace (in mm):**
- Page margins: 20mm all sides
- Between major sections: 8-12mm
- Table cell padding: 3mm vertical, 4mm horizontal
- Row height: 8-10mm
- Logo max height: 16mm
**Table design rules:**
- Borderless with thin horizontal rules (0.3pt, light gray)
- No vertical borders
- Column headers: weight + bottom border (1pt)
- Right-align Qty, Rate, Amount columns
- Zebra striping where specified: white / barely-gray (#f8fafc)
**Implementation approach:**
The file exports one main function `renderInvoicePdf(config, invoice, client, items, businessInfo)` that switches on `config.id` to call the appropriate per-template function. Shared helpers (hexToRgb, drawLogo, truncateText) are defined at the top.
Each per-template function creates a `new jsPDF({ unit: 'mm', format: 'a4' })` and draws its unique layout using jsPDF methods: `setFont`, `setFontSize`, `setTextColor`, `text`, `setFillColor`, `rect`, `setDrawColor`, `line`, `setLineWidth`.
The function returns the jsPDF doc instance.
**IMPORTANT jsPDF notes:**
- jsPDF uses `'helvetica'` font with styles `'normal'`, `'bold'`
- Colors must be set as RGB via `setTextColor(r, g, b)`, `setFillColor(r, g, b)`, `setDrawColor(r, g, b)` — use hexToRgb helper
- Text alignment: `doc.text(str, x, y, { align: 'right' })` for right-aligned text
- A4 size: 210mm × 297mm
- `doc.setFont('helvetica', 'normal')` for regular, `doc.setFont('helvetica', 'bold')` for bold
- For uppercase tracked text: transform the string to uppercase, use `doc.setCharSpace(0.3)` then reset with `doc.setCharSpace(0)`
- For letter-spacing simulation: jsPDF has `setCharSpace(mm)` — use 0.3mm for tracked labels
**Step 1: Write the complete renderer**
Write `invoicePdfRenderer.ts` with:
1. `BusinessInfo` export interface (name, address, email, phone, logo)
2. `hexToRgb` helper
3. `setFill`, `setText`, `setDraw` color helpers that accept hex
4. `drawLogo` helper (if businessInfo.logo is a data URL, use `doc.addImage`)
5. `truncateDesc` helper (truncates long descriptions for table cells)
6. 15 render functions: `renderClean`, `renderProfessional`, `renderBold`, `renderMinimal`, `renderClassic`, `renderModern`, `renderElegant`, `renderCreative`, `renderCompact`, `renderDark`, `renderVibrant`, `renderCorporate`, `renderFresh`, `renderNatural`, `renderStatement`
7. Main `renderInvoicePdf` switch function
Each render function must match its corresponding HTML preview layout. The typography scale must follow the design doc exactly.
**Step 2: Verify**
Run: `npm run build`
Expected: Compiles with no errors.
**Step 3: Commit**
```bash
git add src/utils/invoicePdfRenderer.ts
git commit -m "feat: rewrite PDF renderer with 15 unique typographic layouts"
```
---
### Task 6: Update invoicePdf.ts wrapper
**Files:**
- Modify: `src/utils/invoicePdf.ts`
**Step 1: Update default template ID**
Change the default `templateId` parameter from `'clean-minimal'` to `'clean'`:
```typescript
export function generateInvoicePdf(
invoice: Invoice,
client: Client,
items: InvoiceItem[],
templateId: string = 'clean',
businessInfo?: BusinessInfo,
): jsPDF {
```
**Step 2: Commit**
```bash
git add src/utils/invoicePdf.ts
git commit -m "feat: update invoicePdf wrapper with new default template ID"
```
---
### Task 7: Add template-picker view to Invoices.vue
This is the UX flow change — the core of Part 1 of the design doc.
**Files:**
- Modify: `src/views/Invoices.vue`
**Changes needed:**
1. **View type**: Change `ref<'list' | 'create'>('list')` to `ref<'list' | 'create' | 'template-picker'>('list')`
2. **New state**: Add `pickerInvoiceId = ref<number | null>(null)` to track which invoice is being styled
3. **Remove template picker from create form**: Delete the template selection section (lines 374-384 in current file)
4. **Change handleCreate**: After saving invoice + items, instead of resetting and going to list, navigate to template picker:
```typescript
pickerInvoiceId.value = invoiceId
selectedTemplateId.value = 'clean'
view.value = 'template-picker'
```
5. **Change viewInvoice**: Instead of opening a modal dialog, navigate to the template picker:
```typescript
async function viewInvoice(invoice: Invoice) {
pickerInvoiceId.value = invoice.id!
selectedTemplateId.value = invoice.template_id || 'clean'
// Load items for the picker
try {
previewItems.value = invoice.id ? await invoicesStore.getInvoiceItems(invoice.id) : []
} catch (e) {
console.error('Failed to load invoice items:', e)
previewItems.value = []
}
selectedInvoice.value = invoice
view.value = 'template-picker'
}
```
6. **Remove the old preview dialog**: Delete the "Invoice Preview Dialog" section (lines 405-447) — it's replaced by the template picker view.
7. **Add template-picker view in template**: Add a new `v-else-if="view === 'template-picker'"` block after the create view. This is a full-screen layout:
```html
<!-- Template Picker View -->
<div v-else-if="view === 'template-picker'" class="-m-6 h-[calc(100vh-2rem)] flex flex-col">
<!-- Top bar with invoice info -->
<div class="flex items-center justify-between px-6 py-3 border-b border-border-subtle bg-bg-surface shrink-0">
<div class="flex items-center gap-3">
<button
@click="handlePickerBack"
class="p-1.5 text-text-tertiary hover:text-text-secondary transition-colors"
>
<!-- back arrow SVG -->
</button>
<span class="text-[0.8125rem] font-medium text-text-primary">
{{ selectedInvoice?.invoice_number }}
</span>
<span class="text-[0.75rem] text-text-tertiary">
{{ selectedInvoice ? getClientName(selectedInvoice.client_id) : '' }}
</span>
</div>
<div class="flex items-center gap-3">
<button @click="handlePickerExport" class="px-4 py-2 bg-accent text-bg-base text-[0.75rem] font-medium rounded-lg hover:bg-accent-hover transition-colors">
Export PDF
</button>
<button @click="handlePickerSave" class="px-4 py-2 border border-border-subtle text-text-secondary text-[0.75rem] rounded-lg hover:bg-bg-elevated transition-colors">
Save & Close
</button>
</div>
</div>
<!-- Split pane: template list + preview -->
<div class="flex-1 flex overflow-hidden">
<!-- Left: Template list -->
<div class="w-56 border-r border-border-subtle overflow-y-auto bg-bg-surface shrink-0">
<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">
{{ cat.label }}
</div>
<button
v-for="tmpl in getTemplatesByCategory(cat.id)"
:key="tmpl.id"
class="w-full flex items-center gap-2 px-3 py-1.5 text-[0.75rem] transition-colors"
:class="tmpl.id === selectedTemplateId
? 'bg-accent/10 text-accent-text'
: 'text-text-secondary hover:bg-bg-elevated hover:text-text-primary'"
@click="selectedTemplateId = tmpl.id"
>
<span class="w-2.5 h-2.5 rounded-full shrink-0 border border-black/10" :style="{ backgroundColor: tmpl.colors.primary }" />
<span class="truncate">{{ tmpl.name }}</span>
</button>
</div>
</div>
<!-- Right: Preview -->
<div class="flex-1 bg-bg-inset p-8 overflow-y-auto flex justify-center">
<div class="w-full max-w-lg">
<InvoicePreview
:template="getTemplateById(selectedTemplateId)"
:invoice="selectedInvoice!"
:client="pickerClient"
:items="previewItems"
:business-info="businessInfo"
/>
</div>
</div>
</div>
</div>
```
8. **Add picker helper functions**:
```typescript
const pickerClient = computed<Client | null>(() => {
if (!selectedInvoice.value) return null
return clientsStore.clients.find(c => c.id === selectedInvoice.value!.client_id) || null
})
async function handlePickerSave() {
if (pickerInvoiceId.value) {
await invoicesStore.updateInvoiceTemplate(pickerInvoiceId.value, selectedTemplateId.value)
}
pickerInvoiceId.value = null
view.value = 'list'
}
async function handlePickerExport() {
if (selectedInvoice.value) {
await exportPDF(selectedInvoice.value)
}
}
function handlePickerBack() {
pickerInvoiceId.value = null
view.value = 'list'
}
```
9. **Update handleCreate to navigate to picker**: After saving invoice and items:
```typescript
// Navigate to template picker instead of list
selectedInvoice.value = { ...invoice, id: invoiceId }
pickerInvoiceId.value = invoiceId
selectedTemplateId.value = 'clean'
// Load items for preview
previewItems.value = lineItems.value.map(li => ({
invoice_id: invoiceId,
description: li.description,
quantity: li.quantity,
rate: li.unit_price,
amount: li.quantity * li.unit_price,
}))
// Reset form
createForm.client_id = 0
// ... rest of form reset
lineItems.value = []
view.value = 'template-picker'
```
**IMPORTANT**: Must save the selectedClient BEFORE resetting createForm.client_id, because pickerClient depends on selectedInvoice.
10. **Update selectedTemplateId default**: Change from `'clean-minimal'` to `'clean'`.
11. **Hide tabs when in template-picker view**: Wrap the tab buttons in `v-if="view !== 'template-picker'"`.
**Step 1: Implement all changes above**
**Step 2: Verify**
Run: `npm run build`
Expected: Compiles. No errors.
**Step 3: Commit**
```bash
git add src/views/Invoices.vue
git commit -m "feat: add two-step invoice flow with full-screen template picker"
```
---
### Task 8: Update InvoiceTemplatePicker.vue for new template IDs
**Files:**
- Modify: `src/components/InvoiceTemplatePicker.vue`
The InvoiceTemplatePicker is still used in the create form (now removed) but may be referenced elsewhere. Update:
**Step 1: Update default template ID in sample data**
The component's default data and behavior should reference `'clean'` not `'clean-minimal'`. Since the old create form no longer uses this component (template picker is now in the Invoices view directly), this component may become unused. However, keep it functional in case it's used for other purposes.
No changes needed if all references already go through `getTemplateById`. Just verify it works with the new template IDs.
**Step 2: Verify no broken imports**
Check that InvoiceTemplatePicker.vue still imports from the updated invoiceTemplates.ts correctly. The import paths haven't changed, just the template IDs and config structure — which is the same interface name.
**Step 3: Commit (if any changes were needed)**
```bash
git add src/components/InvoiceTemplatePicker.vue
git commit -m "chore: update InvoiceTemplatePicker for new template IDs"
```
---
### Task 9: Full build and integration test
**Files:** None new — verification only.
**Step 1: Build the Rust backend**
Run: `cd src-tauri && cargo build`
Expected: Compiles with no errors.
**Step 2: Build the frontend**
Run: `npm run build`
Expected: Compiles with no errors (chunk size warning is acceptable).
**Step 3: Manual verification checklist**
Run: `npm run tauri dev` and verify:
1. Create an invoice with line items and a client → clicking "Create Invoice" navigates to the template picker (not back to list)
2. Template picker shows all 15 templates in the left sidebar, grouped by category
3. Clicking different templates shows genuinely different layouts in the preview
4. Each template is immediately visually distinguishable from the others
5. Total amount is the most prominent element in every template
6. "Export PDF" generates a PDF that matches the preview
7. "Save & Close" saves the template choice and returns to list
8. Viewing an existing invoice from the list opens the template picker with its saved template pre-selected
9. Tables use borderless/subtle-rule design (no spreadsheet grids)
10. Typography is readable — body text not too small, headers properly sized
**Step 4: Commit any fixes**
```bash
git add -A
git commit -m "fix: integration test fixes for invoice templates v2"
```
---
## Summary of Template ID Migration
| Old ID | New ID | Template Name |
|---|---|---|
| clean-minimal | clean | Clean |
| corporate-classic | professional | Professional |
| modern-bold | bold | Bold |
| elegant-serif | minimal | Minimal |
| simple-two-tone | classic | Classic |
| gradient-header | modern | Modern |
| sidebar-accent | elegant | Elegant |
| geometric-modern | creative | Creative |
| dark-mode | compact | Compact |
| warm-natural | dark | Dark |
| playful-color-block | vibrant | Vibrant |
| retro-professional | corporate | Corporate |
| tech-minimal | fresh | Fresh |
| executive-premium | natural | Natural |
| data-driven-clean | statement | Statement |
**Note**: Existing invoices in the DB will have old template IDs. The `getTemplateById` function falls back to the first template (Clean) when an ID is not found, so old invoices will gracefully default to Clean. No data migration is needed.

View File

@@ -1,192 +0,0 @@
# ZeroClock Motion System Design
## Personality
Fluid & organic: spring-based easing with slight overshoot, 200-350ms durations for primary animations, 100-150ms for micro-interactions. The app should feel alive and responsive without being distracting.
## Technology
**@vueuse/motion** for declarative spring-physics animations via `v-motion` directives and `useMotion()` composable. Vue's built-in `<Transition>` and `<TransitionGroup>` for enter/leave orchestration. CSS keyframes for ambient/looping animations (pulse, shimmer, float).
## Spring Presets
Define reusable spring configs in a `src/utils/motion.ts` module:
- **snappy**: `{ damping: 20, stiffness: 300 }` — buttons, toggles, small elements
- **smooth**: `{ damping: 15, stiffness: 200 }` — page transitions, modals, cards
- **popIn**: `{ damping: 12, stiffness: 400 }` — tag chips, badges, notifications
## 1. Page Transitions
Wrap `<router-view>` in `App.vue` with `<router-view v-slot="{ Component }">` + `<Transition>`:
- **Leave**: opacity 1 -> 0, translateY 0 -> -8px, 150ms ease-out
- **Enter**: opacity 0 -> 1, translateY 8px -> 0, spring `smooth` preset
- **Mode**: `out-in` to prevent layout overlap in the flex container
CSS classes: `.page-enter-active`, `.page-leave-active`, `.page-enter-from`, `.page-leave-to`
## 2. List Animations
### Entry rows, project cards, client cards
Use `<TransitionGroup>` with staggered enter:
- **Enter**: opacity 0 -> 1, translateY 12px -> 0, spring `smooth`, stagger 30ms per item (via `transition-delay` computed from index)
- **Leave**: opacity 1 -> 0, translateX 0 -> -20px, 150ms ease-in
- **Move**: `transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1)` for reorder
CSS classes: `.list-enter-active`, `.list-leave-active`, `.list-move`, `.list-enter-from`, `.list-leave-to`
### Tag chips
- **Enter**: scale 0.8 -> 1, opacity 0 -> 1, spring `popIn`
- **Leave**: scale 1 -> 0.8, opacity 1 -> 0, 100ms
### Favorites strip pills
- **Enter**: translateX -10px -> 0, opacity 0 -> 1, stagger 50ms
- **Leave**: scale 1 -> 0, opacity 0, 100ms
### Recent entries in Timer
- **Enter**: opacity 0 -> 1, translateX -8px -> 0, stagger 40ms
## 3. Button & Interactive Feedback
### Primary buttons (Start/Stop, Generate, Save, Import)
- `:hover``scale(1.02)`, subtle shadow lift, spring `snappy`
- `:active``scale(0.97)`, shadow compress, instant (no delay)
- Release — spring back to `scale(1)` via `snappy` preset
### Icon buttons (edit, delete, copy, star, repeat)
- `:active``scale(0.85)`, spring back
- Delete icons: `rotate(-10deg)` on active for a "shake" feel
### Nav rail active indicator
Currently the left border appears instantly. Change to:
- Slide the 2px accent bar vertically to match the new active item position
- Use a dedicated `<div>` with `v-motion` and absolute positioning keyed to `currentPath`
- Spring `smooth` preset
### Toggle switches
- Add `cubic-bezier(0.34, 1.56, 0.64, 1)` (overshoot) to the thumb's `transition-transform`
- Duration: 200ms (up from 150ms)
### Project/client cards
- `:hover``translateY(-1px)`, shadow elevation increase, 200ms
- `:active``translateY(0)`, shadow compress
### Accent color picker dots in Settings
- Selected dot: `scale(1.15)` with spring
- Hover: `scale(1.1)`
## 4. Loading & Empty States
### Skeleton shimmer
New CSS keyframe `shimmer`: a gradient highlight sweeping left-to-right on placeholder blocks. Apply via `.skeleton` utility class.
```
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
```
### Content fade-in
When data loads (entries, projects, reports), wrap the content area in a `<Transition>`:
- `opacity: 0` -> `1`, `translateY: 4px` -> `0`, spring `smooth`
- Show skeleton while `loading` ref is true, transition to real content
### Empty state icons
Add gentle vertical float animation to the empty state icons (Timer, BarChart3):
```
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
```
Duration: 3s, ease-in-out, infinite.
## 5. Modal & Dropdown Polish
### Modals
Replace `animate-modal-enter` keyframe with spring-based:
- **Backdrop enter**: opacity 0 -> 1, 200ms ease-out
- **Backdrop leave**: opacity 1 -> 0, 150ms ease-in
- **Panel enter**: scale 0.95 -> 1, opacity 0 -> 1, spring `smooth`
- **Panel leave**: scale 1 -> 0.97, opacity 1 -> 0, 150ms ease-in
Use Vue `<Transition>` on the modal's `v-if` to get proper leave animations (currently missing — modals just vanish).
### Dropdowns (AppSelect, AppTagInput, AppDatePicker)
Replace `animate-dropdown-enter` with spring:
- **Enter**: scale 0.95 -> 1, opacity 0 -> 1, translateY -4px -> 0, spring `snappy`
- **Leave**: opacity 1 -> 0, 100ms (fast close)
### Toast notifications
Keep existing enter/exit keyframes but add slight horizontal slide:
- **Enter**: translateY -20px -> 0, translateX 10px -> 0 (slide from top-right)
- **Exit**: opacity fade + translateY -10px
## 6. Timer-Specific Animations
### Timer start
When the timer transitions from STOPPED to RUNNING:
- The time display pulses once (scale 1 -> 1.03 -> 1) via spring
- The Start button morphs to Stop (color transition already exists, add scale pulse)
### Timer stop
When the timer stops:
- Brief "completed" flash — the time display gets a subtle glow/highlight that fades
### Progress bars (goals, budgets)
- Animate width from 0 to target value on mount, 600ms with spring `smooth`
- Use CSS `transition: width 600ms cubic-bezier(0.22, 1, 0.36, 1)`
## Files to Modify
- `package.json` — add `@vueuse/motion`
- `src/main.ts` — register `MotionPlugin`
- `src/utils/motion.ts` — new, spring preset definitions
- `src/styles/main.css` — new keyframes (shimmer, float), update existing keyframes, add transition utility classes
- `src/App.vue` — page transition wrapper on `<router-view>`
- `src/components/NavRail.vue` — animated active indicator
- `src/components/AppSelect.vue` — dropdown leave animation
- `src/components/AppTagInput.vue` — tag chip enter/leave transitions
- `src/components/ToastNotification.vue` — enhanced enter/exit
- `src/views/Timer.vue` — timer start/stop animations, list transition on recent entries
- `src/views/Entries.vue` — TransitionGroup on entry rows
- `src/views/Projects.vue` — TransitionGroup on project cards, modal transitions
- `src/views/Clients.vue` — TransitionGroup on client cards, modal transitions
- `src/views/Dashboard.vue` — content fade-in, progress bar animation
- `src/views/Reports.vue` — content fade-in on tab switch
- `src/views/Settings.vue` — modal leave transition
- `src/views/CalendarView.vue` — entry block fade-in on week change
- `src/views/TimesheetView.vue` — row fade-in on data load
## Verification
1. `npm run build` passes with no errors
2. Page transitions feel smooth, no layout flashing
3. List add/remove animations don't cause scroll jumps
4. Modals have proper enter AND leave animations
5. No animation on `prefers-reduced-motion: reduce`

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
[],
)?;
// Migrate clients table add new columns (safe to re-run)
// Migrate clients table - add new columns (safe to re-run)
let migration_columns = [
"ALTER TABLE clients ADD COLUMN company TEXT",
"ALTER TABLE clients ADD COLUMN phone TEXT",
@@ -46,7 +46,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
[],
)?;
// Migrate projects table add budget columns (safe to re-run)
// Migrate projects table - add budget columns (safe to re-run)
let project_migrations = [
"ALTER TABLE projects ADD COLUMN budget_hours REAL DEFAULT NULL",
"ALTER TABLE projects ADD COLUMN budget_amount REAL DEFAULT NULL",
@@ -111,7 +111,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
[],
)?;
// Migrate invoices table add template_id column (safe to re-run)
// Migrate invoices table - add template_id column (safe to re-run)
let invoice_migrations = [
"ALTER TABLE invoices ADD COLUMN template_id TEXT DEFAULT 'clean'",
];

View File

@@ -217,7 +217,7 @@
}
/* ============================================
MOTION SYSTEM Transitions & Animations
MOTION SYSTEM - Transitions & Animations
============================================ */
/* Page transitions */

View File

@@ -220,7 +220,7 @@ function pageBreak(doc: jsPDF, y: number, threshold: number = 262): boolean {
// ===========================================================================
// 1. CLEAN Swiss minimalism, single blue accent
// 1. CLEAN - Swiss minimalism, single blue accent
// ===========================================================================
function renderClean(
@@ -316,7 +316,7 @@ function renderClean(
// ===========================================================================
// 2. PROFESSIONAL Navy header band, corporate polish
// 2. PROFESSIONAL - Navy header band, corporate polish
// ===========================================================================
function renderProfessional(
@@ -402,7 +402,7 @@ function renderProfessional(
// ===========================================================================
// 3. BOLD Large indigo block with oversized typography
// 3. BOLD - Large indigo block with oversized typography
// ===========================================================================
function renderBold(
@@ -475,7 +475,7 @@ function renderBold(
drawHeaderText(doc, layout, y, rowH, c.tableHeaderText, padX)
y += rowH
// Rows no borders, no stripes, generous height
// Rows - no borders, no stripes, generous height
for (const item of items) {
if (pageBreak(doc, y)) y = 20
y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX)
@@ -494,7 +494,7 @@ function renderBold(
// ===========================================================================
// 4. MINIMAL Pure monochrome, everything centered
// 4. MINIMAL - Pure monochrome, everything centered
// ===========================================================================
function renderMinimal(
@@ -562,7 +562,7 @@ function renderMinimal(
drawHeaderText(doc, layout, y, rowH, c.primary, padX)
y += rowH
// Rows whitespace separation only
// Rows - whitespace separation only
for (const item of items) {
if (pageBreak(doc, y)) y = 20
y = drawItemRow(doc, item, layout, y, rowH, c.bodyText, padX)
@@ -601,7 +601,7 @@ function renderMinimal(
// ===========================================================================
// 5. CLASSIC Traditional layout, burgundy accents, bordered grid
// 5. CLASSIC - Traditional layout, burgundy accents, bordered grid
// ===========================================================================
function renderClassic(
@@ -712,7 +712,7 @@ function renderClassic(
// ===========================================================================
// 6. MODERN Teal accents, borderless, teal header text
// 6. MODERN - Teal accents, borderless, teal header text
// ===========================================================================
function renderModern(
@@ -837,7 +837,7 @@ function renderModern(
// ===========================================================================
// 7. ELEGANT Gold double-rule accents, centered layout
// 7. ELEGANT - Gold double-rule accents, centered layout
// ===========================================================================
function renderElegant(
@@ -960,7 +960,7 @@ function renderElegant(
// ===========================================================================
// 8. CREATIVE Purple sidebar, card-style rows
// 8. CREATIVE - Purple sidebar, card-style rows
// ===========================================================================
function renderCreative(
@@ -1058,7 +1058,7 @@ function renderCreative(
// ===========================================================================
// 9. COMPACT Data-dense layout with tight spacing
// 9. COMPACT - Data-dense layout with tight spacing
// ===========================================================================
function renderCompact(
@@ -1184,7 +1184,7 @@ function renderCompact(
// ===========================================================================
// 10. DARK Full dark background with cyan highlights
// 10. DARK - Full dark background with cyan highlights
// ===========================================================================
function renderDark(
@@ -1307,7 +1307,7 @@ function renderDark(
// ===========================================================================
// 11. VIBRANT Coral header band, warm tones
// 11. VIBRANT - Coral header band, warm tones
// ===========================================================================
function renderVibrant(
@@ -1390,7 +1390,7 @@ function renderVibrant(
// ===========================================================================
// 12. CORPORATE Blue header with info bar below
// 12. CORPORATE - Blue header with info bar below
// ===========================================================================
function renderCorporate(
@@ -1482,7 +1482,7 @@ function renderCorporate(
// ===========================================================================
// 13. FRESH Oversized watermark invoice number
// 13. FRESH - Oversized watermark invoice number
// ===========================================================================
function renderFresh(
@@ -1581,7 +1581,7 @@ function renderFresh(
// ===========================================================================
// 14. NATURAL Warm beige full-page background, terracotta accents
// 14. NATURAL - Warm beige full-page background, terracotta accents
// ===========================================================================
function renderNatural(
@@ -1684,7 +1684,7 @@ function renderNatural(
// ===========================================================================
// 15. STATEMENT Total-forward design, hero amount top-right
// 15. STATEMENT - Total-forward design, hero amount top-right
// ===========================================================================
function renderStatement(

View File

@@ -15,10 +15,10 @@ export interface CurrencyOption {
}
// ---------------------------------------------------------------------------
// LOCALES ~140 worldwide locale/region combinations
// LOCALES - ~140 worldwide locale/region combinations
// ---------------------------------------------------------------------------
export const LOCALES: LocaleOption[] = [
const _LOCALES_RAW: LocaleOption[] = [
// System default
{ code: 'system', name: 'System Default' },
@@ -234,11 +234,16 @@ export const LOCALES: LocaleOption[] = [
{ code: 'fj-FJ', name: 'Fijian (Fiji)' },
]
export const LOCALES: LocaleOption[] = [
{ code: 'system', name: 'System Default' },
..._LOCALES_RAW.filter(l => l.code !== 'system').sort((a, b) => a.name.localeCompare(b.name)),
]
// ---------------------------------------------------------------------------
// FALLBACK_CURRENCIES ~120+ currencies with English names
// FALLBACK_CURRENCIES - ~120+ currencies with English names
// ---------------------------------------------------------------------------
export const FALLBACK_CURRENCIES: CurrencyOption[] = [
const _FALLBACK_CURRENCIES_RAW: CurrencyOption[] = [
{ code: 'USD', name: 'US Dollar' },
{ code: 'EUR', name: 'Euro' },
{ code: 'GBP', name: 'British Pound' },
@@ -369,6 +374,9 @@ export const FALLBACK_CURRENCIES: CurrencyOption[] = [
{ code: 'BND', name: 'Brunei Dollar' },
]
export const FALLBACK_CURRENCIES: CurrencyOption[] =
[..._FALLBACK_CURRENCIES_RAW].sort((a, b) => a.name.localeCompare(b.name))
// ---------------------------------------------------------------------------
// Currency builder (runtime Intl detection with fallback)
// ---------------------------------------------------------------------------
@@ -383,7 +391,7 @@ function buildCurrencies(): CurrencyOption[] {
return codes.map((code) => ({
code,
name: displayNames.of(code) ?? code,
}))
})).sort((a, b) => a.name.localeCompare(b.name))
} catch {
return FALLBACK_CURRENCIES
}
@@ -423,7 +431,7 @@ export function getCurrencyCode(): string {
// ---------------------------------------------------------------------------
/**
* Short date e.g. "Jan 5, 2025"
* Short date - e.g. "Jan 5, 2025"
* Parses the date-part manually to avoid timezone-shift issues.
*/
export function formatDate(dateString: string): string {
@@ -437,7 +445,7 @@ export function formatDate(dateString: string): string {
}
/**
* Long date e.g. "Monday, January 5, 2025"
* Long date - e.g. "Monday, January 5, 2025"
*/
export function formatDateLong(dateString: string): string {
const [year, month, day] = dateString.substring(0, 10).split('-').map(Number)
@@ -451,7 +459,7 @@ export function formatDateLong(dateString: string): string {
}
/**
* Short date + time e.g. "Jan 5, 2025, 3:42 PM"
* Short date + time - e.g. "Jan 5, 2025, 3:42 PM"
*/
export function formatDateTime(dateString: string): string {
const date = new Date(dateString)
@@ -465,7 +473,7 @@ export function formatDateTime(dateString: string): string {
}
/**
* Full currency format e.g. "$1,234.56"
* Full currency format - e.g. "$1,234.56"
*/
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat(getLocaleCode(), {
@@ -485,7 +493,7 @@ export function formatCurrencyCompact(amount: number): string {
}
/**
* Number with locale grouping e.g. "1,234.56"
* Number with locale grouping - e.g. "1,234.56"
*/
export function formatNumber(amount: number, decimals?: number): string {
return new Intl.NumberFormat(getLocaleCode(), {
@@ -495,7 +503,7 @@ export function formatNumber(amount: number, decimals?: number): string {
}
/**
* Extract just the currency symbol e.g. "$", "\u00a3", "\u20ac"
* Extract just the currency symbol - e.g. "$", "\u00a3", "\u20ac"
*/
export function getCurrencySymbol(): string {
const parts = new Intl.NumberFormat(getLocaleCode(), {

View File

@@ -36,8 +36,10 @@
<!-- Calendar Grid -->
<Transition name="fade" mode="out-in">
<div :key="weekStart.getTime()" class="flex-1 bg-bg-surface rounded-lg border border-border-subtle overflow-hidden flex flex-col min-h-0">
<!-- Day column headers -->
<div class="grid shrink-0 border-b border-border-subtle" :style="gridStyle">
<!-- Scrollable hour rows -->
<div ref="scrollContainer" class="flex-1 overflow-y-auto min-h-0">
<!-- Day column headers (sticky) -->
<div class="grid shrink-0 border-b border-border-subtle sticky top-0 z-10 bg-bg-surface" :style="gridStyle">
<!-- Top-left corner (hour gutter) -->
<div class="w-14 shrink-0 border-r border-border-subtle" />
<!-- Day headers -->
@@ -55,9 +57,6 @@
</span>
</div>
</div>
<!-- Scrollable hour rows -->
<div ref="scrollContainer" class="flex-1 overflow-y-auto min-h-0">
<div class="grid relative" :style="gridStyle">
<!-- Hour labels column -->
<div class="w-14 shrink-0 border-r border-border-subtle">
@@ -138,8 +137,8 @@ const scrollContainer = ref<HTMLElement | null>(null)
// The start-of-week date (Monday)
const weekStart = ref(getMonday(new Date()))
// Hours displayed: 6am through 11pm (6..23)
const HOUR_START = 6
// Hours displayed: full 24h (0..23)
const HOUR_START = 0
const HOUR_END = 23
const hours = Array.from({ length: HOUR_END - HOUR_START + 1 }, (_, i) => HOUR_START + i)
const HOUR_HEIGHT = 48 // h-12 = 3rem = 48px
@@ -251,7 +250,7 @@ function getEntryTooltip(entry: TimeEntry): string {
const duration = formatDuration(entry.duration)
const start = new Date(entry.start_time)
const time = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
const desc = entry.description ? ` ${entry.description}` : ''
const desc = entry.description ? ` - ${entry.description}` : ''
return `${project} (${duration}) at ${time}${desc}`
}

View File

@@ -19,7 +19,7 @@
<p class="text-xs text-text-tertiary mt-1">{{ formattedDate }}</p>
</div>
<!-- Stats row 4 columns -->
<!-- Stats row - 4 columns -->
<div class="grid grid-cols-4 gap-6 mb-8">
<div>
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Today</p>

View File

@@ -149,11 +149,11 @@
>
<p class="text-text-secondary mb-2">Available tokens:</p>
<div class="space-y-1 font-mono text-text-tertiary">
<p><span class="text-accent-text">{YYYY}</span> {{ new Date().getFullYear() }}</p>
<p><span class="text-accent-text">{YY}</span> {{ String(new Date().getFullYear()).slice(-2) }}</p>
<p><span class="text-accent-text">{MM}</span> {{ String(new Date().getMonth() + 1).padStart(2, '0') }}</p>
<p><span class="text-accent-text">{DD}</span> {{ String(new Date().getDate()).padStart(2, '0') }}</p>
<p><span class="text-accent-text">{###}</span> next number ({{ String(invoicesStore.invoices.length + 1).padStart(3, '0') }})</p>
<p><span class="text-accent-text">{YYYY}</span> - {{ new Date().getFullYear() }}</p>
<p><span class="text-accent-text">{YY}</span> - {{ String(new Date().getFullYear()).slice(-2) }}</p>
<p><span class="text-accent-text">{MM}</span> - {{ String(new Date().getMonth() + 1).padStart(2, '0') }}</p>
<p><span class="text-accent-text">{DD}</span> - {{ String(new Date().getDate()).padStart(2, '0') }}</p>
<p><span class="text-accent-text">{###}</span> - next number ({{ String(invoicesStore.invoices.length + 1).padStart(3, '0') }})</p>
</div>
<p class="text-text-tertiary mt-2">e.g. INV-{YYYY}-{###}</p>
</div>
@@ -614,7 +614,7 @@ async function importFromProject() {
if (totalHours > 0) {
lineItems.value.push({
description: `${project.name} ${totalHours.toFixed(1)}h tracked`,
description: `${project.name} - ${totalHours.toFixed(1)}h tracked`,
quantity: parseFloat(totalHours.toFixed(2)),
unit_price: project.hourly_rate
})

View File

@@ -25,7 +25,7 @@
<div class="flex items-start justify-between">
<div>
<h3 class="text-[0.8125rem] font-semibold text-text-primary">{{ project.name }}</h3>
<p class="text-xs text-text-secondary mt-0.5">{{ getClientName(project.client_id) }} · {{ formatCurrency(project.hourly_rate) }}/hr</p>
<p class="text-xs text-text-secondary mt-0.5">{{ getClientName(project.client_id) }} · {{ project.budget_amount ? formatCurrency(project.budget_amount) + ' fixed' : formatCurrency(project.hourly_rate) + '/hr' }}</p>
</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-100">
<button
@@ -86,13 +86,16 @@
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="tryCloseDialog"
>
<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-md p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
<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-2xl p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
<h2 class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">
{{ editingProject ? 'Edit Project' : 'Create Project' }}
</h2>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Name -->
<!-- Two-column layout: Identity | Billing -->
<div class="grid grid-cols-2 gap-6">
<!-- Left column: Identity -->
<div class="space-y-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Name *</label>
<input
@@ -104,7 +107,6 @@
/>
</div>
<!-- Client -->
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Client</label>
<AppSelect
@@ -117,7 +119,41 @@
/>
</div>
<!-- Hourly Rate -->
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Color</label>
<AppColorPicker
v-model="formData.color"
:presets="colorPresets"
/>
</div>
</div>
<!-- Right column: Billing -->
<div class="space-y-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Billing Type</label>
<div class="flex rounded-lg border border-border-subtle overflow-hidden">
<button
type="button"
@click="billingType = 'hourly'"
class="flex-1 px-3 py-1.5 text-[0.75rem] font-medium transition-colors duration-150"
:class="billingType === 'hourly' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:bg-bg-elevated'"
>
Hourly
</button>
<button
type="button"
@click="billingType = 'fixed'"
class="flex-1 px-3 py-1.5 text-[0.75rem] font-medium transition-colors duration-150 border-l border-border-subtle"
:class="billingType === 'fixed' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:bg-bg-elevated'"
>
Fixed Budget
</button>
</div>
</div>
<!-- Hourly billing fields -->
<template v-if="billingType === 'hourly'">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Hourly Rate ({{ getCurrencySymbol() }})</label>
<AppNumberInput
@@ -128,18 +164,6 @@
:prefix="getCurrencySymbol()"
/>
</div>
<!-- Color -->
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Color</label>
<AppColorPicker
v-model="formData.color"
:presets="colorPresets"
/>
</div>
<!-- Budget -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Budget Hours</label>
<AppNumberInput
@@ -151,18 +175,86 @@
placeholder="No limit"
/>
</div>
</template>
<!-- Fixed budget fields -->
<template v-if="billingType === 'fixed'">
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Budget Amount</label>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Budget Amount ({{ getCurrencySymbol() }})</label>
<AppNumberInput
:model-value="formData.budget_amount ?? 0"
@update:model-value="formData.budget_amount = $event || null"
:min="0"
:step="100"
:precision="2"
prefix="$"
:prefix="getCurrencySymbol()"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Budget Hours</label>
<AppNumberInput
:model-value="formData.budget_hours ?? 0"
@update:model-value="formData.budget_hours = $event || null"
:min="0"
:step="1"
:precision="0"
placeholder="No limit"
/>
</div>
</template>
</div>
</div>
<!-- Tasks -->
<div class="border border-border-subtle rounded-lg overflow-hidden">
<button
type="button"
@click="tasksExpanded = !tasksExpanded"
class="w-full flex items-center justify-between px-3 py-2.5 text-[0.8125rem] text-text-primary hover:bg-bg-elevated transition-colors"
>
<span>Tasks ({{ allTasks.length }})</span>
<ChevronDown
class="w-4 h-4 text-text-tertiary transition-transform duration-200"
:class="{ 'rotate-180': tasksExpanded }"
:stroke-width="1.5"
/>
</button>
<div v-if="tasksExpanded" class="border-t border-border-subtle px-3 py-3 space-y-2.5">
<div v-if="allTasks.length > 0" class="space-y-1.5">
<div
v-for="(task, i) in allTasks"
:key="task.id ?? `pending-${i}`"
class="flex items-center justify-between gap-2 px-2.5 py-1.5 bg-bg-inset rounded-lg"
>
<span class="text-[0.75rem] text-text-primary truncate flex-1">{{ task.name }}</span>
<button
type="button"
@click="removeTask(task, i)"
class="p-1 text-text-tertiary hover:text-status-error transition-colors shrink-0"
>
<X class="w-3.5 h-3.5" :stroke-width="1.5" />
</button>
</div>
</div>
<p v-else class="text-[0.6875rem] text-text-tertiary">No tasks. Add tasks to break down project work.</p>
<div class="flex items-center gap-2 pt-1">
<input
v-model="newTaskName"
type="text"
class="flex-1 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="New task name..."
@keydown.enter.prevent="addTask"
/>
<button
type="button"
@click="addTask"
:disabled="!newTaskName.trim()"
class="px-3 py-1.5 bg-accent text-bg-base text-[0.75rem] font-medium rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Add
</button>
</div>
</div>
</div>
<!-- Tracked Apps -->
@@ -288,7 +380,7 @@ import AppSelect from '../components/AppSelect.vue'
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
import RunningAppsPicker from '../components/RunningAppsPicker.vue'
import AppColorPicker from '../components/AppColorPicker.vue'
import { useProjectsStore, type Project } from '../stores/projects'
import { useProjectsStore, type Project, type Task } from '../stores/projects'
import { useClientsStore } from '../stores/clients'
import { useSettingsStore } from '../stores/settings'
import { formatCurrency, getCurrencySymbol } from '../utils/locale'
@@ -326,6 +418,54 @@ const showDeleteDialog = ref(false)
const editingProject = ref<Project | null>(null)
const projectToDelete = ref<Project | null>(null)
// Billing type
const billingType = ref<'hourly' | 'fixed'>('hourly')
// Task state
const projectTasksList = ref<Task[]>([])
const pendingNewTasks = ref<string[]>([])
const pendingRemoveTaskIds = ref<number[]>([])
const newTaskName = ref('')
const tasksExpanded = ref(false)
const allTasks = computed(() => {
const existing = projectTasksList.value.filter(t => !pendingRemoveTaskIds.value.includes(t.id!))
const pending = pendingNewTasks.value.map(name => ({ name, project_id: 0 } as Task))
return [...existing, ...pending]
})
async function loadProjectTasks(projectId: number) {
projectTasksList.value = await projectsStore.fetchTasks(projectId)
}
function addTask() {
const name = newTaskName.value.trim()
if (!name) return
pendingNewTasks.value.push(name)
newTaskName.value = ''
}
function removeTask(task: Task, index: number) {
if (task.id) {
pendingRemoveTaskIds.value.push(task.id)
} else {
// It's a pending task - index offset by existing tasks count
const existingCount = projectTasksList.value.filter(t => !pendingRemoveTaskIds.value.includes(t.id!)).length
pendingNewTasks.value.splice(index - existingCount, 1)
}
}
async function saveTasks(projectId: number) {
for (const id of pendingRemoveTaskIds.value) {
await projectsStore.deleteTask(id)
}
for (const name of pendingNewTasks.value) {
await projectsStore.createTask({ project_id: projectId, name })
}
pendingNewTasks.value = []
pendingRemoveTaskIds.value = []
}
// Tracked apps state
const trackedApps = ref<TrackedApp[]>([])
const pendingAddApps = ref<TrackedApp[]>([])
@@ -455,10 +595,16 @@ function openCreateDialog() {
formData.archived = false
formData.budget_hours = null
formData.budget_amount = null
billingType.value = 'hourly'
trackedApps.value = []
pendingAddApps.value = []
pendingRemoveIds.value = []
trackedAppsExpanded.value = false
projectTasksList.value = []
pendingNewTasks.value = []
pendingRemoveTaskIds.value = []
newTaskName.value = ''
tasksExpanded.value = false
snapshotForm(getFormData())
showDialog.value = true
}
@@ -474,12 +620,21 @@ async function openEditDialog(project: Project) {
formData.archived = project.archived
formData.budget_hours = project.budget_hours ?? null
formData.budget_amount = project.budget_amount ?? null
// Infer billing type from existing data
billingType.value = (project.budget_amount && project.budget_amount > 0) ? 'fixed' : 'hourly'
pendingAddApps.value = []
pendingRemoveIds.value = []
trackedAppsExpanded.value = false
projectTasksList.value = []
pendingNewTasks.value = []
pendingRemoveTaskIds.value = []
newTaskName.value = ''
tasksExpanded.value = false
if (project.id) {
await loadTrackedApps(project.id)
await loadProjectTasks(project.id)
if (trackedApps.value.length > 0) trackedAppsExpanded.value = true
if (projectTasksList.value.length > 0) tasksExpanded.value = true
} else {
trackedApps.value = []
}
@@ -495,6 +650,13 @@ function closeDialog() {
// Handle form submit
async function handleSubmit() {
// Clear irrelevant billing fields based on type
if (billingType.value === 'hourly') {
formData.budget_amount = null
} else {
formData.hourly_rate = 0
}
let projectId: number | undefined
if (editingProject.value) {
await projectsStore.updateProject({ ...formData })
@@ -504,6 +666,7 @@ async function handleSubmit() {
}
if (projectId) {
await saveTrackedApps(projectId)
await saveTasks(projectId)
}
closeDialog()
}

View File

@@ -1,5 +1,4 @@
<template>
<Transition name="fade" appear>
<div class="p-6">
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary mb-6">Reports</h1>
@@ -59,7 +58,7 @@
<!-- Hours Tab -->
<template v-if="activeTab === 'hours'">
<!-- Summary Stats pure typography -->
<!-- Summary Stats - pure typography -->
<div class="grid grid-cols-3 gap-6 mb-8">
<div>
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</p>
@@ -181,12 +180,12 @@
class="border-b border-border-subtle last:border-0"
>
<td class="py-3 pr-4 text-[0.8125rem] text-text-primary">{{ row.project_name }}</td>
<td class="py-3 pr-4 text-[0.75rem] text-text-secondary">{{ row.client_name || '' }}</td>
<td class="py-3 pr-4 text-[0.75rem] text-text-secondary">{{ row.client_name || '-' }}</td>
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-text-primary">{{ row.total_hours.toFixed(1) }}h</td>
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-text-secondary">{{ formatCurrency(row.hourly_rate) }}</td>
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(row.revenue) }}</td>
<td class="py-3 text-right text-[0.75rem] font-mono" :class="row.budget_used_pct != null && row.budget_used_pct > 100 ? 'text-status-error' : 'text-text-secondary'">
{{ row.budget_used_pct != null ? row.budget_used_pct.toFixed(0) + '%' : '' }}
{{ row.budget_used_pct != null ? row.budget_used_pct.toFixed(0) + '%' : '-' }}
</td>
</tr>
</tbody>
@@ -199,7 +198,6 @@
</div>
</template>
</div>
</Transition>
</template>
<script setup lang="ts">

View File

@@ -160,7 +160,7 @@
</button>
</div>
<!-- Idle sub-settings progressive disclosure -->
<!-- Idle sub-settings - progressive disclosure -->
<div v-if="idleDetection" class="space-y-5 pl-4 border-l-2 border-border-subtle ml-1">
<div class="flex items-center justify-between">
<div>

View File

@@ -3,15 +3,23 @@
<!-- Hero timer display -->
<div class="text-center pt-4 pb-8">
<div class="relative inline-block">
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2" :class="timerPulseClass">
<span class="text-text-primary">{{ timerParts.hours }}</span>
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
<span class="text-text-primary">{{ timerParts.minutes }}</span>
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
<span class="text-text-primary">{{ timerParts.seconds }}</span>
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2 transition-colors duration-300" :class="[timerPulseClass, timerStore.isPaused ? 'opacity-60' : '']">
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.hours }}</span>
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.minutes }}</span>
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">:</span>
<span :class="timerStore.isPaused ? 'text-text-tertiary' : 'text-text-primary'">{{ timerParts.seconds }}</span>
</p>
<!-- Paused badge - absolutely positioned so it doesn't shift layout -->
<span
v-if="timerStore.isPaused"
class="absolute -bottom-1 left-1/2 -translate-x-1/2 text-[0.625rem] font-semibold uppercase tracking-[0.15em] px-2.5 py-0.5 rounded-full transition-opacity duration-200"
:class="timerStore.timerState === 'PAUSED_IDLE' ? 'text-status-warning bg-status-warning/10' : timerStore.timerState === 'PAUSED_MANUAL' ? 'text-status-warning bg-status-warning/10' : 'text-status-info bg-status-info/10'"
>
{{ timerStore.timerState === 'PAUSED_IDLE' ? 'Idle' : timerStore.timerState === 'PAUSED_MANUAL' ? 'Paused' : 'App hidden' }}
</span>
<button
v-if="timerStore.isRunning"
v-if="!timerStore.isStopped"
@click="openMiniTimer"
class="absolute -right-8 top-1/2 -translate-y-1/2 p-2 text-text-tertiary hover:text-text-secondary transition-colors"
title="Pop out mini timer"
@@ -20,16 +28,26 @@
</button>
</div>
<!-- Paused indicator -->
<p
v-if="timerStore.isPaused"
class="text-[0.75rem] font-medium mb-4"
:class="timerStore.timerState === 'PAUSED_IDLE' ? 'text-status-warning' : 'text-status-info'"
>
{{ timerStore.timerState === 'PAUSED_IDLE' ? 'Paused (idle)' : 'Paused (app not visible)' }}
</p>
<div v-else class="mb-4" />
<div class="h-6" />
<div class="flex items-center justify-center gap-3">
<!-- Pause / Resume button (visible when timer is active) -->
<button
v-if="timerStore.isRunning"
@click="timerStore.pauseManual()"
class="px-6 py-3 text-sm font-medium rounded-lg transition-colors duration-150 bg-status-warning/15 text-status-warning hover:bg-status-warning/25"
>
Pause
</button>
<button
v-else-if="timerStore.timerState === 'PAUSED_MANUAL'"
@click="timerStore.resumeFromPause()"
class="px-6 py-3 text-sm font-medium rounded-lg transition-colors duration-150 bg-accent text-bg-base hover:bg-accent-hover"
>
Resume
</button>
<!-- Start / Stop button -->
<button
@click="toggleTimer"
class="btn-primary px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
@@ -38,6 +56,7 @@
{{ buttonLabel }}
</button>
</div>
</div>
<!-- Favorites strip -->
<div v-if="favorites.length > 0" class="max-w-[36rem] mx-auto mb-4">