Files
zeroclock/docs/plans/2026-02-17-ui-improvements-batch-implementation.md

1263 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# UI Improvements Batch Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add worldwide locale-aware formatting, custom datetime picker, custom number input with press-and-hold, fix modal margins, and fix default hourly rate bug.
**Architecture:** Create shared locale utility with Intl API wrappers, then sweep all views to replace hardcoded formatting. New AppNumberInput component follows the existing UI Scale pattern. AppSelect gets a searchable mode for the 160+ currency / 130+ locale dropdowns.
**Tech Stack:** Vue 3 Composition API, Tailwind CSS v4 with @theme tokens, Lucide icons, Pinia stores, browser Intl APIs
---
### Task 1: Create locale utility with comprehensive worldwide data
**Files:**
- Create: `src/utils/locale.ts`
**Step 1: Create the locale utility**
Create `src/utils/locale.ts` with comprehensive locale/currency lists and Intl-based formatting functions. The locale list covers every major language/region combination worldwide. Currencies are sourced from `Intl.supportedValuesOf('currency')` at runtime with a hardcoded fallback.
```typescript
import { useSettingsStore } from '../stores/settings'
// ── Comprehensive Locale List ─────────────────────────────────────
// BCP 47 locale tags with English display names, grouped by region.
// This covers ~140 locales spanning every continent.
export interface LocaleOption {
code: string
name: string
}
export const LOCALES: LocaleOption[] = [
// System default
{ code: 'system', name: 'System Default' },
// English variants
{ code: 'en-US', name: 'English (United States)' },
{ code: 'en-GB', name: 'English (United Kingdom)' },
{ code: 'en-AU', name: 'English (Australia)' },
{ code: 'en-CA', name: 'English (Canada)' },
{ code: 'en-NZ', name: 'English (New Zealand)' },
{ code: 'en-IE', name: 'English (Ireland)' },
{ code: 'en-IN', name: 'English (India)' },
{ code: 'en-ZA', name: 'English (South Africa)' },
{ code: 'en-SG', name: 'English (Singapore)' },
{ code: 'en-HK', name: 'English (Hong Kong)' },
{ code: 'en-PH', name: 'English (Philippines)' },
{ code: 'en-NG', name: 'English (Nigeria)' },
{ code: 'en-KE', name: 'English (Kenya)' },
{ code: 'en-GH', name: 'English (Ghana)' },
{ code: 'en-PK', name: 'English (Pakistan)' },
// Spanish variants
{ code: 'es-ES', name: 'Spanish (Spain)' },
{ code: 'es-MX', name: 'Spanish (Mexico)' },
{ code: 'es-AR', name: 'Spanish (Argentina)' },
{ code: 'es-CO', name: 'Spanish (Colombia)' },
{ code: 'es-CL', name: 'Spanish (Chile)' },
{ code: 'es-PE', name: 'Spanish (Peru)' },
{ code: 'es-VE', name: 'Spanish (Venezuela)' },
{ code: 'es-EC', name: 'Spanish (Ecuador)' },
{ code: 'es-GT', name: 'Spanish (Guatemala)' },
{ code: 'es-CU', name: 'Spanish (Cuba)' },
{ code: 'es-BO', name: 'Spanish (Bolivia)' },
{ code: 'es-DO', name: 'Spanish (Dominican Republic)' },
{ code: 'es-HN', name: 'Spanish (Honduras)' },
{ code: 'es-PY', name: 'Spanish (Paraguay)' },
{ code: 'es-SV', name: 'Spanish (El Salvador)' },
{ code: 'es-NI', name: 'Spanish (Nicaragua)' },
{ code: 'es-CR', name: 'Spanish (Costa Rica)' },
{ code: 'es-PA', name: 'Spanish (Panama)' },
{ code: 'es-UY', name: 'Spanish (Uruguay)' },
{ code: 'es-PR', name: 'Spanish (Puerto Rico)' },
// Portuguese
{ code: 'pt-BR', name: 'Portuguese (Brazil)' },
{ code: 'pt-PT', name: 'Portuguese (Portugal)' },
{ code: 'pt-AO', name: 'Portuguese (Angola)' },
{ code: 'pt-MZ', name: 'Portuguese (Mozambique)' },
// French
{ code: 'fr-FR', name: 'French (France)' },
{ code: 'fr-BE', name: 'French (Belgium)' },
{ code: 'fr-CA', name: 'French (Canada)' },
{ code: 'fr-CH', name: 'French (Switzerland)' },
{ code: 'fr-LU', name: 'French (Luxembourg)' },
{ code: 'fr-SN', name: 'French (Senegal)' },
{ code: 'fr-CI', name: 'French (Ivory Coast)' },
{ code: 'fr-CM', name: 'French (Cameroon)' },
{ code: 'fr-CD', name: 'French (Congo)' },
{ code: 'fr-MA', name: 'French (Morocco)' },
{ code: 'fr-TN', name: 'French (Tunisia)' },
{ code: 'fr-DZ', name: 'French (Algeria)' },
// German
{ code: 'de-DE', name: 'German (Germany)' },
{ code: 'de-AT', name: 'German (Austria)' },
{ code: 'de-CH', name: 'German (Switzerland)' },
{ code: 'de-LU', name: 'German (Luxembourg)' },
{ code: 'de-LI', name: 'German (Liechtenstein)' },
// Italian
{ code: 'it-IT', name: 'Italian (Italy)' },
{ code: 'it-CH', name: 'Italian (Switzerland)' },
// Dutch
{ code: 'nl-NL', name: 'Dutch (Netherlands)' },
{ code: 'nl-BE', name: 'Dutch (Belgium)' },
// Scandinavian
{ code: 'sv-SE', name: 'Swedish (Sweden)' },
{ code: 'sv-FI', name: 'Swedish (Finland)' },
{ code: 'nb-NO', name: 'Norwegian Bokmål (Norway)' },
{ code: 'nn-NO', name: 'Norwegian Nynorsk (Norway)' },
{ code: 'da-DK', name: 'Danish (Denmark)' },
{ code: 'fi-FI', name: 'Finnish (Finland)' },
{ code: 'is-IS', name: 'Icelandic (Iceland)' },
// Baltic
{ code: 'et-EE', name: 'Estonian (Estonia)' },
{ code: 'lv-LV', name: 'Latvian (Latvia)' },
{ code: 'lt-LT', name: 'Lithuanian (Lithuania)' },
// Central/Eastern Europe
{ code: 'pl-PL', name: 'Polish (Poland)' },
{ code: 'cs-CZ', name: 'Czech (Czech Republic)' },
{ code: 'sk-SK', name: 'Slovak (Slovakia)' },
{ code: 'hu-HU', name: 'Hungarian (Hungary)' },
{ code: 'ro-RO', name: 'Romanian (Romania)' },
{ code: 'bg-BG', name: 'Bulgarian (Bulgaria)' },
{ code: 'hr-HR', name: 'Croatian (Croatia)' },
{ code: 'sr-RS', name: 'Serbian (Serbia)' },
{ code: 'sl-SI', name: 'Slovenian (Slovenia)' },
{ code: 'bs-BA', name: 'Bosnian (Bosnia)' },
{ code: 'mk-MK', name: 'Macedonian (North Macedonia)' },
{ code: 'sq-AL', name: 'Albanian (Albania)' },
// Eastern Europe / Central Asia
{ code: 'ru-RU', name: 'Russian (Russia)' },
{ code: 'uk-UA', name: 'Ukrainian (Ukraine)' },
{ code: 'be-BY', name: 'Belarusian (Belarus)' },
{ code: 'ka-GE', name: 'Georgian (Georgia)' },
{ code: 'hy-AM', name: 'Armenian (Armenia)' },
{ code: 'az-AZ', name: 'Azerbaijani (Azerbaijan)' },
{ code: 'kk-KZ', name: 'Kazakh (Kazakhstan)' },
{ code: 'uz-UZ', name: 'Uzbek (Uzbekistan)' },
// Greek / Turkish / Cypriot
{ code: 'el-GR', name: 'Greek (Greece)' },
{ code: 'el-CY', name: 'Greek (Cyprus)' },
{ code: 'tr-TR', name: 'Turkish (Turkey)' },
// Arabic
{ code: 'ar-SA', name: 'Arabic (Saudi Arabia)' },
{ code: 'ar-EG', name: 'Arabic (Egypt)' },
{ code: 'ar-AE', name: 'Arabic (UAE)' },
{ code: 'ar-MA', name: 'Arabic (Morocco)' },
{ code: 'ar-DZ', name: 'Arabic (Algeria)' },
{ code: 'ar-TN', name: 'Arabic (Tunisia)' },
{ code: 'ar-IQ', name: 'Arabic (Iraq)' },
{ code: 'ar-JO', name: 'Arabic (Jordan)' },
{ code: 'ar-LB', name: 'Arabic (Lebanon)' },
{ code: 'ar-KW', name: 'Arabic (Kuwait)' },
{ code: 'ar-QA', name: 'Arabic (Qatar)' },
{ code: 'ar-BH', name: 'Arabic (Bahrain)' },
{ code: 'ar-OM', name: 'Arabic (Oman)' },
{ code: 'ar-YE', name: 'Arabic (Yemen)' },
{ code: 'ar-LY', name: 'Arabic (Libya)' },
{ code: 'ar-SD', name: 'Arabic (Sudan)' },
{ code: 'ar-SY', name: 'Arabic (Syria)' },
{ code: 'ar-PS', name: 'Arabic (Palestine)' },
// Hebrew / Persian
{ code: 'he-IL', name: 'Hebrew (Israel)' },
{ code: 'fa-IR', name: 'Persian (Iran)' },
{ code: 'fa-AF', name: 'Dari (Afghanistan)' },
{ code: 'ps-AF', name: 'Pashto (Afghanistan)' },
// South Asian
{ code: 'hi-IN', name: 'Hindi (India)' },
{ code: 'bn-BD', name: 'Bengali (Bangladesh)' },
{ code: 'bn-IN', name: 'Bengali (India)' },
{ code: 'ta-IN', name: 'Tamil (India)' },
{ code: 'ta-LK', name: 'Tamil (Sri Lanka)' },
{ code: 'te-IN', name: 'Telugu (India)' },
{ code: 'mr-IN', name: 'Marathi (India)' },
{ code: 'gu-IN', name: 'Gujarati (India)' },
{ code: 'kn-IN', name: 'Kannada (India)' },
{ code: 'ml-IN', name: 'Malayalam (India)' },
{ code: 'pa-IN', name: 'Punjabi (India)' },
{ code: 'or-IN', name: 'Odia (India)' },
{ code: 'as-IN', name: 'Assamese (India)' },
{ code: 'ur-PK', name: 'Urdu (Pakistan)' },
{ code: 'si-LK', name: 'Sinhala (Sri Lanka)' },
{ code: 'ne-NP', name: 'Nepali (Nepal)' },
{ code: 'my-MM', name: 'Burmese (Myanmar)' },
// Southeast Asian
{ code: 'th-TH', name: 'Thai (Thailand)' },
{ code: 'vi-VN', name: 'Vietnamese (Vietnam)' },
{ code: 'id-ID', name: 'Indonesian (Indonesia)' },
{ code: 'ms-MY', name: 'Malay (Malaysia)' },
{ code: 'ms-SG', name: 'Malay (Singapore)' },
{ code: 'fil-PH', name: 'Filipino (Philippines)' },
{ code: 'km-KH', name: 'Khmer (Cambodia)' },
{ code: 'lo-LA', name: 'Lao (Laos)' },
// East Asian
{ code: 'zh-CN', name: 'Chinese (Simplified, China)' },
{ code: 'zh-TW', name: 'Chinese (Traditional, Taiwan)' },
{ code: 'zh-HK', name: 'Chinese (Hong Kong)' },
{ code: 'zh-SG', name: 'Chinese (Singapore)' },
{ code: 'ja-JP', name: 'Japanese (Japan)' },
{ code: 'ko-KR', name: 'Korean (South Korea)' },
{ code: 'mn-MN', name: 'Mongolian (Mongolia)' },
// African
{ code: 'sw-KE', name: 'Swahili (Kenya)' },
{ code: 'sw-TZ', name: 'Swahili (Tanzania)' },
{ code: 'am-ET', name: 'Amharic (Ethiopia)' },
{ code: 'af-ZA', name: 'Afrikaans (South Africa)' },
{ code: 'zu-ZA', name: 'Zulu (South Africa)' },
{ code: 'xh-ZA', name: 'Xhosa (South Africa)' },
{ code: 'yo-NG', name: 'Yoruba (Nigeria)' },
{ code: 'ig-NG', name: 'Igbo (Nigeria)' },
{ code: 'ha-NG', name: 'Hausa (Nigeria)' },
{ code: 'rw-RW', name: 'Kinyarwanda (Rwanda)' },
{ code: 'so-SO', name: 'Somali (Somalia)' },
{ code: 'mg-MG', name: 'Malagasy (Madagascar)' },
// Iberian minority
{ code: 'ca-ES', name: 'Catalan (Spain)' },
{ code: 'eu-ES', name: 'Basque (Spain)' },
{ code: 'gl-ES', name: 'Galician (Spain)' },
// Celtic / British Isles minority
{ code: 'cy-GB', name: 'Welsh (United Kingdom)' },
{ code: 'ga-IE', name: 'Irish (Ireland)' },
{ code: 'gd-GB', name: 'Scottish Gaelic (United Kingdom)' },
// Oceanian
{ code: 'mi-NZ', name: 'Māori (New Zealand)' },
{ code: 'to-TO', name: 'Tongan (Tonga)' },
{ code: 'sm-WS', name: 'Samoan (Samoa)' },
{ code: 'fj-FJ', name: 'Fijian (Fiji)' },
]
// ── Currency List ─────────────────────────────────────────────────
// Built dynamically from Intl API, with hardcoded fallback.
export interface CurrencyOption {
code: string
name: string
}
const FALLBACK_CURRENCIES: CurrencyOption[] = [
{ code: 'USD', name: 'US Dollar' },
{ code: 'EUR', name: 'Euro' },
{ code: 'GBP', name: 'British Pound' },
{ code: 'JPY', name: 'Japanese Yen' },
{ code: 'CNY', name: 'Chinese Yuan' },
{ code: 'KRW', name: 'South Korean Won' },
{ code: 'INR', name: 'Indian Rupee' },
{ code: 'CAD', name: 'Canadian Dollar' },
{ code: 'AUD', name: 'Australian Dollar' },
{ code: 'CHF', name: 'Swiss Franc' },
{ code: 'SEK', name: 'Swedish Krona' },
{ code: 'NOK', name: 'Norwegian Krone' },
{ code: 'DKK', name: 'Danish Krone' },
{ code: 'NZD', name: 'New Zealand Dollar' },
{ code: 'SGD', name: 'Singapore Dollar' },
{ code: 'HKD', name: 'Hong Kong Dollar' },
{ code: 'TWD', name: 'New Taiwan Dollar' },
{ code: 'BRL', name: 'Brazilian Real' },
{ code: 'MXN', name: 'Mexican Peso' },
{ code: 'ARS', name: 'Argentine Peso' },
{ code: 'CLP', name: 'Chilean Peso' },
{ code: 'COP', name: 'Colombian Peso' },
{ code: 'PEN', name: 'Peruvian Sol' },
{ code: 'ZAR', name: 'South African Rand' },
{ code: 'RUB', name: 'Russian Ruble' },
{ code: 'TRY', name: 'Turkish Lira' },
{ code: 'PLN', name: 'Polish Zloty' },
{ code: 'CZK', name: 'Czech Koruna' },
{ code: 'HUF', name: 'Hungarian Forint' },
{ code: 'RON', name: 'Romanian Leu' },
{ code: 'BGN', name: 'Bulgarian Lev' },
{ code: 'HRK', name: 'Croatian Kuna' },
{ code: 'RSD', name: 'Serbian Dinar' },
{ code: 'UAH', name: 'Ukrainian Hryvnia' },
{ code: 'GEL', name: 'Georgian Lari' },
{ code: 'AED', name: 'UAE Dirham' },
{ code: 'SAR', name: 'Saudi Riyal' },
{ code: 'QAR', name: 'Qatari Riyal' },
{ code: 'KWD', name: 'Kuwaiti Dinar' },
{ code: 'BHD', name: 'Bahraini Dinar' },
{ code: 'OMR', name: 'Omani Rial' },
{ code: 'JOD', name: 'Jordanian Dinar' },
{ code: 'EGP', name: 'Egyptian Pound' },
{ code: 'MAD', name: 'Moroccan Dirham' },
{ code: 'TND', name: 'Tunisian Dinar' },
{ code: 'ILS', name: 'Israeli New Shekel' },
{ code: 'IRR', name: 'Iranian Rial' },
{ code: 'IQD', name: 'Iraqi Dinar' },
{ code: 'PKR', name: 'Pakistani Rupee' },
{ code: 'BDT', name: 'Bangladeshi Taka' },
{ code: 'LKR', name: 'Sri Lankan Rupee' },
{ code: 'NPR', name: 'Nepalese Rupee' },
{ code: 'MMK', name: 'Myanmar Kyat' },
{ code: 'THB', name: 'Thai Baht' },
{ code: 'VND', name: 'Vietnamese Dong' },
{ code: 'IDR', name: 'Indonesian Rupiah' },
{ code: 'MYR', name: 'Malaysian Ringgit' },
{ code: 'PHP', name: 'Philippine Peso' },
{ code: 'KHR', name: 'Cambodian Riel' },
{ code: 'LAK', name: 'Lao Kip' },
{ code: 'MNT', name: 'Mongolian Tugrik' },
{ code: 'KZT', name: 'Kazakhstani Tenge' },
{ code: 'UZS', name: 'Uzbekistani Som' },
{ code: 'AZN', name: 'Azerbaijani Manat' },
{ code: 'AMD', name: 'Armenian Dram' },
{ code: 'BYN', name: 'Belarusian Ruble' },
{ code: 'NGN', name: 'Nigerian Naira' },
{ code: 'KES', name: 'Kenyan Shilling' },
{ code: 'GHS', name: 'Ghanaian Cedi' },
{ code: 'TZS', name: 'Tanzanian Shilling' },
{ code: 'UGX', name: 'Ugandan Shilling' },
{ code: 'ETB', name: 'Ethiopian Birr' },
{ code: 'RWF', name: 'Rwandan Franc' },
{ code: 'XOF', name: 'West African CFA Franc' },
{ code: 'XAF', name: 'Central African CFA Franc' },
{ code: 'DZD', name: 'Algerian Dinar' },
{ code: 'LYD', name: 'Libyan Dinar' },
{ code: 'SDG', name: 'Sudanese Pound' },
{ code: 'SOS', name: 'Somali Shilling' },
{ code: 'MGA', name: 'Malagasy Ariary' },
{ code: 'MUR', name: 'Mauritian Rupee' },
{ code: 'ISK', name: 'Icelandic Króna' },
{ code: 'ALL', name: 'Albanian Lek' },
{ code: 'MKD', name: 'Macedonian Denar' },
{ code: 'BAM', name: 'Bosnia-Herzegovina Mark' },
{ code: 'MDL', name: 'Moldovan Leu' },
{ code: 'UYU', name: 'Uruguayan Peso' },
{ code: 'PYG', name: 'Paraguayan Guaraní' },
{ code: 'BOB', name: 'Bolivian Boliviano' },
{ code: 'VES', name: 'Venezuelan Bolívar' },
{ code: 'DOP', name: 'Dominican Peso' },
{ code: 'GTQ', name: 'Guatemalan Quetzal' },
{ code: 'HNL', name: 'Honduran Lempira' },
{ code: 'NIO', name: 'Nicaraguan Córdoba' },
{ code: 'CRC', name: 'Costa Rican Colón' },
{ code: 'PAB', name: 'Panamanian Balboa' },
{ code: 'CUP', name: 'Cuban Peso' },
{ code: 'JMD', name: 'Jamaican Dollar' },
{ code: 'TTD', name: 'Trinidad Dollar' },
{ code: 'HTG', name: 'Haitian Gourde' },
{ code: 'AWG', name: 'Aruban Florin' },
{ code: 'BBD', name: 'Barbadian Dollar' },
{ code: 'BZD', name: 'Belize Dollar' },
{ code: 'FJD', name: 'Fijian Dollar' },
{ code: 'PGK', name: 'Papua New Guinean Kina' },
{ code: 'WST', name: 'Samoan Tala' },
{ code: 'TOP', name: 'Tongan Paʻanga' },
{ code: 'SBD', name: 'Solomon Islands Dollar' },
{ code: 'VUV', name: 'Vanuatu Vatu' },
{ code: 'SCR', name: 'Seychellois Rupee' },
{ code: 'MVR', name: 'Maldivian Rufiyaa' },
{ code: 'BTN', name: 'Bhutanese Ngultrum' },
{ code: 'AFN', name: 'Afghan Afghani' },
{ code: 'SYP', name: 'Syrian Pound' },
{ code: 'LBP', name: 'Lebanese Pound' },
{ code: 'YER', name: 'Yemeni Rial' },
{ code: 'BWP', name: 'Botswanan Pula' },
{ code: 'ZMW', name: 'Zambian Kwacha' },
{ code: 'MZN', name: 'Mozambican Metical' },
{ code: 'AOA', name: 'Angolan Kwanza' },
{ code: 'NAD', name: 'Namibian Dollar' },
{ code: 'SZL', name: 'Eswatini Lilangeni' },
{ code: 'LSL', name: 'Lesotho Loti' },
{ code: 'GMD', name: 'Gambian Dalasi' },
{ code: 'SLL', name: 'Sierra Leonean Leone' },
{ code: 'GNF', name: 'Guinean Franc' },
{ code: 'LRD', name: 'Liberian Dollar' },
{ code: 'CVE', name: 'Cape Verdean Escudo' },
{ code: 'STN', name: 'São Tomé Dobra' },
{ code: 'ERN', name: 'Eritrean Nakfa' },
{ code: 'DJF', name: 'Djiboutian Franc' },
{ code: 'KMF', name: 'Comorian Franc' },
{ code: 'BIF', name: 'Burundian Franc' },
{ code: 'CDF', name: 'Congolese Franc' },
{ code: 'MWK', name: 'Malawian Kwacha' },
{ code: 'ZWL', name: 'Zimbabwean Dollar' },
]
function buildCurrencies(): CurrencyOption[] {
try {
const codes = (Intl as any).supportedValuesOf('currency') as string[]
const displayNames = new Intl.DisplayNames(['en'], { type: 'currency' })
return codes.map(code => ({
code,
name: displayNames.of(code) || code,
}))
} catch {
return FALLBACK_CURRENCIES
}
}
let _currencies: CurrencyOption[] | null = null
export function getCurrencies(): CurrencyOption[] {
if (!_currencies) _currencies = buildCurrencies()
return _currencies
}
// ── Resolved Locale ──────────────────────────────────────────────
// Reads from settings store. Falls back to navigator.language.
function getLocaleCode(): string {
try {
const store = useSettingsStore()
const saved = store.settings.locale
if (saved && saved !== 'system') return saved
} catch { /* store not ready */ }
return navigator.language || 'en-US'
}
function getCurrencyCode(): string {
try {
const store = useSettingsStore()
const saved = store.settings.currency
if (saved) return saved
} catch { /* store not ready */ }
return 'USD'
}
// ── Formatting Functions ─────────────────────────────────────────
export function formatDate(dateString: string): string {
if (!dateString) return '-'
const [y, m, d] = dateString.includes('T')
? dateString.split('T')[0].split('-').map(Number)
: dateString.split('-').map(Number)
const date = new Date(y, m - 1, d)
return date.toLocaleDateString(getLocaleCode(), {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
export function formatDateLong(dateString: string): string {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleDateString(getLocaleCode(), {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function formatDateTime(dateString: string): string {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleDateString(getLocaleCode(), {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat(getLocaleCode(), {
style: 'currency',
currency: getCurrencyCode(),
}).format(amount)
}
export function formatCurrencyCompact(amount: number): string {
// For inline display like "50.00/hr" — just the number with locale formatting
return new Intl.NumberFormat(getLocaleCode(), {
style: 'currency',
currency: getCurrencyCode(),
}).format(amount)
}
export function formatNumber(amount: number, decimals = 2): string {
return new Intl.NumberFormat(getLocaleCode(), {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(amount)
}
export function getCurrencySymbol(): string {
const parts = new Intl.NumberFormat(getLocaleCode(), {
style: 'currency',
currency: getCurrencyCode(),
}).formatToParts(0)
const symbolPart = parts.find(p => p.type === 'currency')
return symbolPart?.value || getCurrencyCode()
}
```
**Step 2: Verify it compiles**
Run: `npm run build`
Expected: may fail until views are updated, but the utility itself should have no syntax errors
**Step 3: Commit**
```bash
git add src/utils/locale.ts
git commit -m "feat: add comprehensive locale utility with 140+ locales and 120+ currencies"
```
---
### Task 2: Add searchable prop to AppSelect
**Files:**
- Modify: `src/components/AppSelect.vue`
**Step 1: Add searchable functionality**
Add a `searchable` boolean prop. When true, show a text input at the top of the dropdown that filters options by label. The search input auto-focuses when the dropdown opens.
Changes to the `<script>` section:
1. Add `searchable` prop (default `false`) and a `searchQuery` ref
2. Add a `filteredItems` computed that filters `allItems` by search query when searchable
3. Reset `searchQuery` on open
4. Use `filteredItems` instead of `allItems` in the template
5. Add a search input at the top of the dropdown panel (inside the `max-h` scroll area, above options)
6. Keep the placeholder item always visible (not filtered out)
Changes to the `<template>` section:
Add this search input block inside the dropdown panel, before the options `div`:
```html
<div v-if="searchable" class="px-2 pt-2 pb-1">
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
class="w-full px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
placeholder="Search..."
@keydown="onSearchKeydown"
/>
</div>
```
The `onSearchKeydown` handler should pass arrow/enter/escape through to the existing `onKeydown` logic.
In the `open()` function, add `searchQuery.value = ''` and after `nextTick`, focus the search input if searchable.
Replace all references to `allItems` in the template's `v-for` with `filteredItems`.
The `filteredItems` computed:
```typescript
const searchQuery = ref('')
const searchInputRef = ref<HTMLInputElement | null>(null)
const filteredItems = computed(() => {
if (!props.searchable || !searchQuery.value) return allItems.value
const q = searchQuery.value.toLowerCase()
return allItems.value.filter(item => {
if (item._isPlaceholder) return true
return getOptionLabel(item).toLowerCase().includes(q)
})
})
```
In `open()`, after the existing `nextTick`:
```typescript
if (props.searchable) {
searchQuery.value = ''
nextTick(() => searchInputRef.value?.focus())
}
```
**Step 2: Verify it builds**
Run: `npm run build`
Expected: builds with no errors
**Step 3: Commit**
```bash
git add src/components/AppSelect.vue
git commit -m "feat: add searchable prop to AppSelect for filtering long option lists"
```
---
### Task 3: Add locale and currency settings to Settings.vue
**Files:**
- Modify: `src/views/Settings.vue`
**Step 1: Add locale and currency dropdowns to the General tab**
Import `AppSelect` and the locale data:
```typescript
import AppSelect from '../components/AppSelect.vue'
import { LOCALES, getCurrencies } from '../utils/locale'
```
Add state refs:
```typescript
const locale = ref('system')
const currency = ref('USD')
const currencyOptions = getCurrencies()
```
In the General tab section, after the UI Scale row, add two new rows:
**Locale row:**
```html
<div class="flex items-center justify-between mt-5">
<div>
<p class="text-[0.8125rem] text-text-primary">Locale</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Date and number formatting</p>
</div>
<div class="w-56">
<AppSelect
v-model="locale"
:options="LOCALES"
label-key="name"
value-key="code"
placeholder="System Default"
:placeholder-value="'system'"
:searchable="true"
@update:model-value="saveLocaleSettings"
/>
</div>
</div>
```
**Currency row:**
```html
<div class="flex items-center justify-between mt-5">
<div>
<p class="text-[0.8125rem] text-text-primary">Currency</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Currency symbol and formatting</p>
</div>
<div class="w-56">
<AppSelect
v-model="currency"
:options="currencyOptions"
label-key="name"
value-key="code"
placeholder="US Dollar"
:placeholder-value="'USD'"
:searchable="true"
@update:model-value="saveLocaleSettings"
/>
</div>
</div>
```
Add save function:
```typescript
async function saveLocaleSettings() {
await settingsStore.updateSetting('locale', locale.value)
await settingsStore.updateSetting('currency', currency.value)
}
```
In `onMounted`, load the values:
```typescript
locale.value = settingsStore.settings.locale || 'system'
currency.value = settingsStore.settings.currency || 'USD'
```
**Step 2: Verify it builds**
Run: `npm run build`
Expected: builds with no errors
**Step 3: Commit**
```bash
git add src/views/Settings.vue
git commit -m "feat: add locale and currency settings with searchable dropdowns"
```
---
### Task 4: Replace all hardcoded formatting across all views
**Files:**
- Modify: `src/views/Dashboard.vue`
- Modify: `src/views/Entries.vue`
- Modify: `src/views/Timer.vue`
- Modify: `src/views/Invoices.vue`
- Modify: `src/views/Reports.vue`
- Modify: `src/views/Projects.vue`
- Modify: `src/components/AppDatePicker.vue`
**Step 1: Update each file**
Import the locale helpers in each file that has hardcoded formatting:
```typescript
import { formatDate, formatDateTime, formatCurrency, formatDateLong, getCurrencySymbol } from '../utils/locale'
```
Then make these specific replacements:
**Dashboard.vue:**
- Line 120: Replace `new Date().toLocaleDateString('en-US', { weekday: 'long', ... })` with `formatDateLong(new Date().toISOString())`
**Entries.vue:**
- Delete the local `formatDate` function (~line 219)
- Import `formatDate` from locale utils instead
- The function signature is the same — takes a date string, returns formatted string
**Timer.vue:**
- Delete the local `formatDate` function (~line 153)
- Import `formatDateTime` from locale utils
- Replace usage: `formatDate(entry.start_time)``formatDateTime(entry.start_time)` (Timer shows date+time)
**Invoices.vue:**
- Delete the local `formatDate` function
- Import `formatDate, formatCurrency` from locale utils
- Replace all `${{ invoice.total.toFixed(2) }}` with `{{ formatCurrency(invoice.total) }}`
- Replace all `${{ ... .toFixed(2) }}` in the create form calculated totals and the detail dialog
- Replace `$` prefix before `{{ createForm.discount.toFixed(2) }}` with `formatCurrency(createForm.discount)` etc.
**Reports.vue:**
- Import `formatCurrency` from locale utils
- Replace `${{ reportData.totalEarnings?.toFixed(2) || '0.00' }}` with `{{ formatCurrency(reportData.totalEarnings || 0) }}`
- Replace `${{ ((projectData.total_seconds / 3600) * getProjectRate(projectData.project_id)).toFixed(2) }}` with `{{ formatCurrency((projectData.total_seconds / 3600) * getProjectRate(projectData.project_id)) }}`
**Projects.vue:**
- Import `formatCurrency` from locale utils
- Replace `${{ project.hourly_rate.toFixed(2) }}/hr` with `{{ formatCurrency(project.hourly_rate) }}/hr`
**AppDatePicker.vue:**
- Line 32-36: Replace `date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })` with locale-aware version using `getLocaleCode()` from the utils or simply import and use `formatDate`
- Line 40-41: Replace `date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })` with `date.toLocaleDateString(getLocaleCode(), { month: 'long', year: 'numeric' })` — import `getLocaleCode` or just hardcode reading from settings
For AppDatePicker, the simplest approach is to add a helper that reads the locale and not import the full locale module (to avoid circular deps). Add a local `getLocale()` function:
```typescript
function getLocale(): string {
try {
const app = document.getElementById('app')
// Read from settings store via a simpler path
return navigator.language || 'en-US'
} catch { return 'en-US' }
}
```
Actually, the cleanest approach: export `getLocaleCode` from locale.ts and import it directly in AppDatePicker. There's no circular dependency since locale.ts imports from stores, not components.
**Step 2: Verify it builds**
Run: `npm run build`
Expected: builds with no errors
**Step 3: Commit**
```bash
git add src/views/ src/components/AppDatePicker.vue
git commit -m "feat: replace all hardcoded en-US and $ formatting with locale-aware helpers"
```
---
### Task 5: Create AppNumberInput component
**Files:**
- Create: `src/components/AppNumberInput.vue`
**Step 1: Create the component**
```vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Minus, Plus } from 'lucide-vue-next'
interface Props {
modelValue: number
min?: number
max?: number
step?: number
precision?: number
prefix?: string
suffix?: string
}
const props = withDefaults(defineProps<Props>(), {
min: 0,
max: Infinity,
step: 1,
precision: 0,
prefix: '',
suffix: '',
})
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const isEditing = ref(false)
const editValue = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
// Display value (formatted)
const displayValue = computed(() => {
return props.modelValue.toFixed(props.precision)
})
// Clamp and emit
function setValue(val: number) {
const clamped = Math.min(props.max, Math.max(props.min, val))
const rounded = parseFloat(clamped.toFixed(props.precision))
emit('update:modelValue', rounded)
}
function increment() {
setValue(props.modelValue + props.step)
}
function decrement() {
setValue(props.modelValue - props.step)
}
// ── Press-and-hold ───────────────────────────────────────────────
let holdTimeout: ReturnType<typeof setTimeout> | null = null
let holdInterval: ReturnType<typeof setInterval> | null = null
function startHold(action: () => void) {
action() // Fire immediately
holdTimeout = setTimeout(() => {
holdInterval = setInterval(action, 80)
}, 400)
}
function stopHold() {
if (holdTimeout) { clearTimeout(holdTimeout); holdTimeout = null }
if (holdInterval) { clearInterval(holdInterval); holdInterval = null }
}
// ── Inline editing ───────────────────────────────────────────────
function startEdit() {
isEditing.value = true
editValue.value = displayValue.value
setTimeout(() => inputRef.value?.select(), 0)
}
function commitEdit() {
isEditing.value = false
const parsed = parseFloat(editValue.value)
if (!isNaN(parsed)) {
setValue(parsed)
}
}
function cancelEdit() {
isEditing.value = false
}
</script>
<template>
<div class="flex items-center gap-2">
<!-- Minus button -->
<button
type="button"
@mousedown.prevent="startHold(decrement)"
@mouseup="stopHold"
@mouseleave="stopHold"
@touchstart.prevent="startHold(decrement)"
@touchend="stopHold"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
:disabled="modelValue <= min"
>
<Minus class="w-3.5 h-3.5" :stroke-width="2" />
</button>
<!-- Value display / edit -->
<div
v-if="!isEditing"
@click="startEdit"
class="min-w-[4rem] text-center text-[0.8125rem] font-mono text-text-primary cursor-text select-none"
>
<span v-if="prefix" class="text-text-tertiary">{{ prefix }}</span>
{{ displayValue }}
<span v-if="suffix" class="text-text-tertiary ml-0.5">{{ suffix }}</span>
</div>
<input
v-else
ref="inputRef"
v-model="editValue"
type="text"
inputmode="decimal"
class="w-20 text-center px-1 py-0.5 bg-bg-inset border border-accent rounded-lg text-[0.8125rem] font-mono text-text-primary focus:outline-none"
@blur="commitEdit"
@keydown.enter="commitEdit"
@keydown.escape="cancelEdit"
/>
<!-- Plus button -->
<button
type="button"
@mousedown.prevent="startHold(increment)"
@mouseup="stopHold"
@mouseleave="stopHold"
@touchstart.prevent="startHold(increment)"
@touchend="stopHold"
class="w-8 h-8 flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
:disabled="modelValue >= max"
>
<Plus class="w-3.5 h-3.5" :stroke-width="2" />
</button>
</div>
</template>
```
**Step 2: Verify it builds**
Run: `npm run build`
Expected: builds with no errors
**Step 3: Commit**
```bash
git add src/components/AppNumberInput.vue
git commit -m "feat: add AppNumberInput component with press-and-hold repeat"
```
---
### Task 6: Replace all native number inputs with AppNumberInput
**Files:**
- Modify: `src/views/Settings.vue` (hourly rate)
- Modify: `src/views/Projects.vue` (hourly rate in dialog)
- Modify: `src/views/Entries.vue` (duration in edit dialog)
- Modify: `src/views/Invoices.vue` (tax rate, discount)
**Step 1: Replace in each file**
Import in each file:
```typescript
import AppNumberInput from '../components/AppNumberInput.vue'
```
**Settings.vue — Default Hourly Rate:**
Replace the `<input type="number">` for hourly rate with:
```html
<AppNumberInput
v-model="hourlyRate"
:min="0"
:step="1"
:precision="2"
prefix="$"
@update:model-value="saveSettings"
/>
```
Remove the `$` span that was before the input.
**Projects.vue — Hourly Rate in dialog:**
Replace the `<input type="number">` for hourly rate with:
```html
<AppNumberInput
v-model="formData.hourly_rate"
:min="0"
:step="1"
:precision="2"
prefix="$"
/>
```
**Entries.vue — Duration in edit dialog:**
Replace the `<input type="number">` for duration with:
```html
<AppNumberInput
v-model="durationMinutes"
:min="1"
:step="1"
suffix="min"
/>
```
Note: `durationMinutes` is already a computed with getter/setter, and `AppNumberInput` emits numbers, so this works directly.
**Invoices.vue — Tax Rate and Discount:**
Replace tax rate input:
```html
<AppNumberInput
v-model="createForm.tax_rate"
:min="0"
:max="100"
:step="0.5"
:precision="2"
suffix="%"
/>
```
Replace discount input:
```html
<AppNumberInput
v-model="createForm.discount"
:min="0"
:step="1"
:precision="2"
prefix="$"
/>
```
**Step 2: Verify it builds**
Run: `npm run build`
Expected: builds with no errors
**Step 3: Commit**
```bash
git add src/views/Settings.vue src/views/Projects.vue src/views/Entries.vue src/views/Invoices.vue
git commit -m "feat: replace native number inputs with AppNumberInput across all views"
```
---
### Task 7: Custom datetime picker for Entries edit dialog
**Files:**
- Modify: `src/views/Entries.vue`
**Step 1: Replace datetime-local input with AppDatePicker + time inputs**
In the edit dialog, replace the `<input type="datetime-local">` block with:
```html
<!-- Start Date & Time -->
<div>
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Start Date & Time</label>
<div class="flex items-center gap-2">
<div class="flex-1">
<AppDatePicker
v-model="editDate"
placeholder="Date"
/>
</div>
<div class="flex items-center gap-1">
<input
v-model="editHour"
type="number"
min="0"
max="23"
class="w-12 px-2 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary text-center font-mono focus:outline-none focus:border-border-visible"
/>
<span class="text-text-tertiary text-sm font-mono">:</span>
<input
v-model="editMinute"
type="number"
min="0"
max="59"
class="w-12 px-2 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary text-center font-mono focus:outline-none focus:border-border-visible"
/>
</div>
</div>
</div>
```
Add new refs for the split date/time:
```typescript
const editDate = ref('')
const editHour = ref(0)
const editMinute = ref(0)
```
In `openEditDialog`, split the start_time:
```typescript
function openEditDialog(entry: TimeEntry) {
editingEntry.value = entry
editForm.id = entry.id || 0
editForm.project_id = entry.project_id
editForm.description = entry.description || ''
editForm.duration = entry.duration
// Split start_time into date and time parts
const dt = new Date(entry.start_time)
const y = dt.getFullYear()
const m = String(dt.getMonth() + 1).padStart(2, '0')
const d = String(dt.getDate()).padStart(2, '0')
editDate.value = `${y}-${m}-${d}`
editHour.value = dt.getHours()
editMinute.value = dt.getMinutes()
showEditDialog.value = true
}
```
In `handleEdit`, reconstruct the start_time:
```typescript
async function handleEdit() {
if (editingEntry.value) {
const [y, m, d] = editDate.value.split('-').map(Number)
const start = new Date(y, m - 1, d, editHour.value, editMinute.value)
const end = new Date(start.getTime() + editForm.duration * 1000)
const updatedEntry: TimeEntry = {
id: editForm.id,
project_id: editForm.project_id,
description: editForm.description || undefined,
start_time: start.toISOString(),
end_time: end.toISOString(),
duration: editForm.duration
}
await entriesStore.updateEntry(updatedEntry)
closeEditDialog()
}
}
```
Remove the old `editForm.start_time` usage since we now use `editDate`/`editHour`/`editMinute`.
**Step 2: Verify it builds**
Run: `npm run build`
Expected: builds with no errors
**Step 3: Commit**
```bash
git add src/views/Entries.vue
git commit -m "feat: replace native datetime-local with custom date picker + time inputs"
```
---
### Task 8: Fix modal viewport margins across all dialogs
**Files:**
- Modify: `src/views/Clients.vue`
- Modify: `src/views/Projects.vue`
- Modify: `src/views/Entries.vue`
- Modify: `src/views/Invoices.vue`
- Modify: `src/views/Settings.vue`
**Step 1: Add padding to all modal overlays**
In every file, find each `class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center z-50"` and add `p-4` to the class list:
```
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
```
Then on each modal content `div` inside, ensure `max-h` accounts for the padding. Replace any `max-h-[85vh]` with `max-h-[calc(100vh-2rem)]`. For modals that don't have a `max-h` yet, add `max-h-[calc(100vh-2rem)] overflow-y-auto`.
**Inventory of dialogs to update:**
1. **Clients.vue** — Create/Edit dialog (already has `max-h-[85vh]`), Delete dialog
2. **Projects.vue** — Create/Edit dialog, Delete dialog
3. **Entries.vue** — Edit dialog, Delete dialog
4. **Invoices.vue** — Detail dialog (already has `max-h-[90vh]`), Delete dialog
5. **Settings.vue** — Clear Data dialog
For each: add `p-4` to the overlay, ensure the inner modal has `max-h-[calc(100vh-2rem)] overflow-y-auto`. Remove the separate `mx-4` from the inner modal since the overlay `p-4` now provides horizontal spacing too.
**Step 2: Verify it builds**
Run: `npm run build`
Expected: builds with no errors
**Step 3: Commit**
```bash
git add src/views/
git commit -m "fix: add viewport margin to all modal dialogs"
```
---
### Task 9: Fix default hourly rate in Projects.vue
**Files:**
- Modify: `src/views/Projects.vue`
**Step 1: Read default hourly rate from settings**
Import the settings store:
```typescript
import { useSettingsStore } from '../stores/settings'
```
Add to the setup:
```typescript
const settingsStore = useSettingsStore()
```
In `openCreateDialog()`, replace `formData.hourly_rate = 0` with:
```typescript
formData.hourly_rate = parseFloat(settingsStore.settings.hourly_rate) || 0
```
In `onMounted`, add `settingsStore.fetchSettings()` to the `Promise.all`:
```typescript
onMounted(async () => {
await Promise.all([
projectsStore.fetchProjects(),
clientsStore.fetchClients(),
settingsStore.fetchSettings()
])
})
```
**Step 2: Verify it builds**
Run: `npm run build`
Expected: builds with no errors
**Step 3: Commit**
```bash
git add src/views/Projects.vue
git commit -m "fix: apply default hourly rate from settings when creating new projects"
```
---
### Task 10: Build and verify end-to-end
**Step 1: Full build check**
Run: `npm run build`
Expected: builds with no errors
**Step 2: Verify no regressions**
Run greps to confirm:
- No remaining `'en-US'` hardcoded locale strings in views: `grep -r "en-US" src/views/`
- No remaining `${{` hardcoded currency in views: `grep -rn '\${{' src/views/`
- No remaining `type="number"` in views (except reminder interval in Settings): `grep -rn 'type="number"' src/views/`
- No remaining `type="datetime-local"`: `grep -rn 'datetime-local' src/views/`
**Step 3: Final commit if any fixups needed**
```bash
git add -A
git commit -m "feat: complete UI improvements batch — locale, number input, datetime, modals"
```