diff --git a/.gitignore b/.gitignore index 3c3629e..b9833c6 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index a8f6fda..53b119f 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -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'", ]; diff --git a/src/styles/main.css b/src/styles/main.css index 946ca4d..06fa537 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -217,7 +217,7 @@ } /* ============================================ - MOTION SYSTEM — Transitions & Animations + MOTION SYSTEM - Transitions & Animations ============================================ */ /* Page transitions */ diff --git a/src/utils/invoicePdfRenderer.ts b/src/utils/invoicePdfRenderer.ts index 60e1079..fe352da 100644 --- a/src/utils/invoicePdfRenderer.ts +++ b/src/utils/invoicePdfRenderer.ts @@ -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( diff --git a/src/utils/locale.ts b/src/utils/locale.ts index 71c4290..72bfe50 100644 --- a/src/utils/locale.ts +++ b/src/utils/locale.ts @@ -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(), { diff --git a/src/views/CalendarView.vue b/src/views/CalendarView.vue index e611772..07f421f 100644 --- a/src/views/CalendarView.vue +++ b/src/views/CalendarView.vue @@ -36,28 +36,27 @@
- -
- -
- -
- - {{ formatDayHeader(day) }} - -
-
-
+ +
+ +
+ +
+ + {{ formatDayHeader(day) }} + +
+
@@ -138,8 +137,8 @@ const scrollContainer = ref(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}` } diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index cae6383..4210eeb 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -19,7 +19,7 @@

{{ formattedDate }}

- +

Today

diff --git a/src/views/Invoices.vue b/src/views/Invoices.vue index fb8aadb..3dbba8a 100644 --- a/src/views/Invoices.vue +++ b/src/views/Invoices.vue @@ -149,11 +149,11 @@ >

Available tokens:

-

{YYYY} — {{ new Date().getFullYear() }}

-

{YY} — {{ String(new Date().getFullYear()).slice(-2) }}

-

{MM} — {{ String(new Date().getMonth() + 1).padStart(2, '0') }}

-

{DD} — {{ String(new Date().getDate()).padStart(2, '0') }}

-

{###} — next number ({{ String(invoicesStore.invoices.length + 1).padStart(3, '0') }})

+

{YYYY} - {{ new Date().getFullYear() }}

+

{YY} - {{ String(new Date().getFullYear()).slice(-2) }}

+

{MM} - {{ String(new Date().getMonth() + 1).padStart(2, '0') }}

+

{DD} - {{ String(new Date().getDate()).padStart(2, '0') }}

+

{###} - next number ({{ String(invoicesStore.invoices.length + 1).padStart(3, '0') }})

e.g. INV-{YYYY}-{###}

@@ -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 }) diff --git a/src/views/Projects.vue b/src/views/Projects.vue index bd0b1ea..7f7b047 100644 --- a/src/views/Projects.vue +++ b/src/views/Projects.vue @@ -25,7 +25,7 @@

{{ project.name }}

-

{{ getClientName(project.client_id) }} · {{ formatCurrency(project.hourly_rate) }}/hr

+

{{ getClientName(project.client_id) }} · {{ project.budget_amount ? formatCurrency(project.budget_amount) + ' fixed' : formatCurrency(project.hourly_rate) + '/hr' }}

+ +
+
+ + + + + + +
+
+ + +
+ +
+
+
+ {{ task.name }} + +
+
+

No tasks. Add tasks to break down project work.

+
+ + +
@@ -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(null) const projectToDelete = ref(null) +// Billing type +const billingType = ref<'hourly' | 'fixed'>('hourly') + +// Task state +const projectTasksList = ref([]) +const pendingNewTasks = ref([]) +const pendingRemoveTaskIds = ref([]) +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([]) const pendingAddApps = ref([]) @@ -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() } diff --git a/src/views/Reports.vue b/src/views/Reports.vue index 9f4bbaf..58dbaa7 100644 --- a/src/views/Reports.vue +++ b/src/views/Reports.vue @@ -1,5 +1,4 @@