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

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,28 +36,27 @@
<!-- 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">
<!-- Top-left corner (hour gutter) -->
<div class="w-14 shrink-0 border-r border-border-subtle" />
<!-- Day headers -->
<div
v-for="(day, index) in weekDays"
:key="index"
class="px-2 py-2.5 text-center border-r border-border-subtle last:border-r-0"
:class="isToday(day) ? 'bg-accent/5' : ''"
>
<span
class="text-[0.6875rem] uppercase tracking-[0.08em] font-medium"
:class="isToday(day) ? 'text-accent-text' : 'text-text-tertiary'"
>
{{ formatDayHeader(day) }}
</span>
</div>
</div>
<!-- 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 -->
<div
v-for="(day, index) in weekDays"
:key="index"
class="px-2 py-2.5 text-center border-r border-border-subtle last:border-r-0"
:class="isToday(day) ? 'bg-accent/5' : ''"
>
<span
class="text-[0.6875rem] uppercase tracking-[0.08em] font-medium"
:class="isToday(day) ? 'text-accent-text' : 'text-text-tertiary'"
>
{{ formatDayHeader(day) }}
</span>
</div>
</div>
<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,82 +86,174 @@
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 -->
<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="Project name"
/>
</div>
<!-- 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
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="Project name"
/>
</div>
<!-- Client -->
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Client</label>
<AppSelect
v-model="formData.client_id"
:options="clientsStore.clients"
label-key="name"
value-key="id"
placeholder="No client"
:placeholder-value="undefined"
/>
</div>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Client</label>
<AppSelect
v-model="formData.client_id"
:options="clientsStore.clients"
label-key="name"
value-key="id"
placeholder="No client"
:placeholder-value="undefined"
/>
</div>
<!-- Hourly Rate -->
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Hourly Rate ({{ getCurrencySymbol() }})</label>
<AppNumberInput
v-model="formData.hourly_rate"
:min="0"
:step="1"
:precision="2"
: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
:model-value="formData.budget_hours ?? 0"
@update:model-value="formData.budget_hours = $event || null"
:min="0"
:step="1"
:precision="0"
placeholder="No limit"
/>
<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>
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Budget Amount</label>
<AppNumberInput
:model-value="formData.budget_amount ?? 0"
@update:model-value="formData.budget_amount = $event || null"
:min="0"
:step="100"
:precision="2"
prefix="$"
placeholder="No limit"
<!-- 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
v-model="formData.hourly_rate"
:min="0"
:step="1"
:precision="2"
: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>
<!-- 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 ({{ getCurrencySymbol() }})</label>
<AppNumberInput
:model-value="formData.budget_amount ?? 0"
@update:model-value="formData.budget_amount = $event || null"
:min="0"
:step="100"
:precision="2"
: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>
@@ -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,23 +28,34 @@
</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" />
<button
@click="toggleTimer"
class="btn-primary px-10 py-3 text-sm font-medium rounded-lg transition-colors duration-150"
:class="buttonClass"
>
{{ buttonLabel }}
</button>
<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"
:class="buttonClass"
>
{{ buttonLabel }}
</button>
</div>
</div>
<!-- Favorites strip -->