diff --git a/docs/plans/2026-02-17-ui-improvements-batch-implementation.md b/docs/plans/2026-02-17-ui-improvements-batch-implementation.md new file mode 100644 index 0000000..199f76e --- /dev/null +++ b/docs/plans/2026-02-17-ui-improvements-batch-implementation.md @@ -0,0 +1,1262 @@ +# 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 ` + + +``` + +**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 `` for hourly rate with: +```html + +``` + +Remove the `$` span that was before the input. + +**Projects.vue — Hourly Rate in dialog:** + +Replace the `` for hourly rate with: +```html + +``` + +**Entries.vue — Duration in edit dialog:** + +Replace the `` for duration with: +```html + +``` + +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 + +``` + +Replace discount input: +```html + +``` + +**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 `` block with: + +```html + +
+ +
+
+ +
+
+ + : + +
+
+
+``` + +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" +```