1263 lines
40 KiB
Markdown
1263 lines
40 KiB
Markdown
# 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"
|
||
```
|