Skip to content

Conversation

@nicomiguelino
Copy link
Contributor

@nicomiguelino nicomiguelino commented Dec 24, 2025

User description

Implements locale-aware utilities in edge-apps-library to prevent code duplication across Vue-based Edge Apps.

Changes

  • Locale and timezone resolution with override settings and validation
  • Localized date/time formatting utilities (day names, month names, time formatting)
  • Hour format detection (12h/24h) with locale awareness
  • Proper AM/PM localization support

Checklist

  • Write unit tests.
  • Write a utility function that formats the date (year, month, and date).
  • Update the Edge Apps library documentation.

PR Type

Enhancement, Tests, Documentation


Description

  • Add locale/timezone utilities with overrides

  • Implement localized date/time formatting

  • Provide day and month names helpers

  • Add comprehensive unit tests across locales


Diagram Walkthrough

flowchart LR
  settings["Settings overrides"] -- "validated" --> timezone["getTimeZone()"]
  metadata["Screen metadata (GPS)"] -- "fallback" --> timezone
  timezone -- "used by" --> formatTime["formatTime()"]
  locale["getLocale()"] -- "validated fallback chain" --> formatting["Formatting utilities"]
  formatting["formatLocalizedDate(), getLocalizedDayNames(), getLocalizedMonthNames(), detectHourFormat()"] -- "exported utilities" --> README["README updates"]
  tests["Unit tests (bun)"] -- "cover utilities" --> formatting
Loading

File Walkthrough

Relevant files
Enhancement
1 files
locale.ts
Add locale/timezone resolution and formatting utilities   
+299/-9 
Tests
6 files
detect-hour-format.test.ts
Tests for 12h/24h hour format detection                                   
+54/-0   
format-localized-date.test.ts
Tests for locale-aware date formatting                                     
+73/-0   
format-time.test.ts
Tests for locale and timezone time formatting                       
+136/-0 
get-localized-day-names.test.ts
Tests for localized weekday names helpers                               
+212/-0 
get-localized-month-names.test.ts
Tests for localized month names helpers                                   
+360/-0 
locale.test.ts
Make timezone tests async for new API                                       
+6/-6     
Documentation
1 files
README.md
Document new localization utilities                                           
+6/-1     

- Add getLocale() and getTimeZone() with override settings and validation
- Add getLocalizedDayNames() and getLocalizedMonthNames() formatters
- Add detectHourFormat() for 12h/24h format detection
- Add formatTime() with locale and timezone awareness
@github-actions
Copy link

github-actions bot commented Dec 24, 2025

PR Reviewer Guide 🔍

(Review updated until commit c3ae600)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Fragile Locale Assumptions

Several tests assert exact strings for locales with localized numerals and AM/PM casing (e.g., zh-CN, th-TH, hi-IN). These can vary across runtimes/ICU versions and may cause flaky tests. Consider relaxing expectations or mocking Intl.

test('should format time correctly for zh-CN locale (24-hour)', () => {
  const result = formatTime(testDate, 'zh-CN', 'UTC')
  expect(result.hour).toBe('一四')
  expect(result.minute).toBe('三〇')
  expect(result.second).toBe('四五')
  expect(result.dayPeriod).toBeUndefined()
  // Chinese locale uses Chinese numerals: 一四:三〇:四五
  expect(result.formatted).toBe('一四:三〇:四五')
})

test('should format time correctly for th-TH locale (24-hour)', () => {
  const result = formatTime(testDate, 'th-TH', 'UTC')
  expect(result.hour).toBe('๑๔')
  expect(result.minute).toBe('๓๐')
  expect(result.second).toBe('๔๕')
  expect(result.dayPeriod).toBeUndefined()
  // Thai locale uses Thai numerals: ๑๔:๓๐:๔๕
  expect(result.formatted).toBe('๑๔:๓๐:๔๕')
})
Locale Validation Strictness

The isValidLocale check requires requested and resolved language codes to match. This may reject valid BCP 47 tags that alias to different language subtags (e.g., deprecated/alias tags). Review if this is desired or if Intl.getCanonicalLocales should be used.

function isValidLocale(locale: string): boolean {
  try {
    const formatter = new Intl.DateTimeFormat(locale)
    const resolved = formatter.resolvedOptions().locale
    // Check if the resolved locale's language code matches the requested one
    // e.g., 'zh-CN' → language 'zh', 'en-US' → language 'en'
    const requestedLanguage = locale.toLowerCase().split('-')[0]
    const resolvedLanguage = resolved.toLowerCase().split('-')[0]
    return requestedLanguage === resolvedLanguage
  } catch {
    return false
  }
}
Day Names Calculation

getLocalizedDayNames builds names starting from first Sunday of the year using UTC. Depending on locale week definitions and DST/timezone, results may not align with local expectations. Consider using a fixed reference date sequence or Intl with weekday only without date math assumptions.

export function getLocalizedDayNames(locale: string): {
  full: string[]
  short: string[]
} {
  const full: string[] = []
  const short: string[] = []

  // Find the first Sunday of the current year
  const now = new Date()
  const year = now.getFullYear()
  const firstDay = new Date(Date.UTC(year, 0, 1))
  const dayOfWeek = firstDay.getUTCDay() // 0 = Sunday, 1 = Monday, etc.

  // Calculate offset to get to the first Sunday
  const offset = dayOfWeek === 0 ? 0 : 7 - dayOfWeek
  const firstSunday = new Date(Date.UTC(year, 0, 1 + offset))

  for (let i = 0; i < 7; i++) {
    const date = new Date(firstSunday)
    date.setUTCDate(firstSunday.getUTCDate() + i)

    full.push(date.toLocaleDateString(locale, { weekday: 'long' }))
    short.push(date.toLocaleDateString(locale, { weekday: 'short' }))
  }

  return { full, short }
}

@github-actions
Copy link

github-actions bot commented Dec 24, 2025

PR Code Suggestions ✨

Latest suggestions up to c3ae600
Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard against invalid coordinates

Validate the numeric range of latitude and longitude before calling tzlookup.
Passing out-of-range values (e.g., NaN or invalid ranges) can throw and
unnecessarily hit the UTC fallback; guard early to avoid misleading fallbacks.

edge-apps/edge-apps-library/src/utils/locale.ts [41-63]

 export async function getTimeZone(): Promise<string> {
-  // Priority 1: Use override setting if provided and valid
-  const overrideTimezone = getSettingWithDefault<string>(
-    'override_timezone',
-    '',
-  )
+  const overrideTimezone = getSettingWithDefault<string>('override_timezone', '')
   if (overrideTimezone) {
-    // Validate using native Intl API
     if (isValidTimezone(overrideTimezone)) {
       return overrideTimezone
     }
-    console.warn(
-      `Invalid timezone override: "${overrideTimezone}", falling back to GPS detection`,
-    )
+    console.warn(`Invalid timezone override: "${overrideTimezone}", falling back to GPS detection`)
   }
 
   try {
     const [latitude, longitude] = getMetadata().coordinates
+    if (
+      typeof latitude !== 'number' ||
+      typeof longitude !== 'number' ||
+      Number.isNaN(latitude) ||
+      Number.isNaN(longitude) ||
+      latitude < -90 || latitude > 90 ||
+      longitude < -180 || longitude > 180
+    ) {
+      console.warn('Invalid coordinates, using UTC fallback')
+      return 'UTC'
+    }
     return tzlookup(latitude, longitude)
   } catch (error) {
     console.warn('Failed to get timezone from coordinates, using UTC:', error)
     return 'UTC'
   }
 }
Suggestion importance[1-10]: 7

__

Why: Adding coordinate range/NaN validation before tzlookup is accurate and reduces unnecessary fallbacks, improving robustness. It's a reasonable enhancement with moderate impact; not critical but helps prevent misleading UTC defaults.

Medium
General
Use fixed UTC week for weekdays

Avoid using the current year's calendar to derive weekday names; DST/leap year and
calendar quirks can cause locale mismatches. Generate names deterministically from a
fixed ISO week anchored at UTC to ensure consistent ordering Sunday–Saturday across
years.

edge-apps/edge-apps-library/src/utils/locale.ts [161-187]

 export function getLocalizedDayNames(locale: string): {
   full: string[]
   short: string[]
 } {
   const full: string[] = []
   const short: string[] = []
 
-  // Find the first Sunday of the current year
-  const now = new Date()
-  const year = now.getFullYear()
-  const firstDay = new Date(Date.UTC(year, 0, 1))
-  const dayOfWeek = firstDay.getUTCDay() // 0 = Sunday, 1 = Monday, etc.
-
-  // Calculate offset to get to the first Sunday
-  const offset = dayOfWeek === 0 ? 0 : 7 - dayOfWeek
-  const firstSunday = new Date(Date.UTC(year, 0, 1 + offset))
-
+  // Use a fixed known week starting on Sunday in UTC to avoid year-specific variability
+  const anchorSunday = new Date(Date.UTC(2023, 0, 1)) // 2023-01-01 is a Sunday
   for (let i = 0; i < 7; i++) {
-    const date = new Date(firstSunday)
-    date.setUTCDate(firstSunday.getUTCDate() + i)
-
-    full.push(date.toLocaleDateString(locale, { weekday: 'long' }))
-    short.push(date.toLocaleDateString(locale, { weekday: 'short' }))
+    const date = new Date(anchorSunday)
+    date.setUTCDate(anchorSunday.getUTCDate() + i)
+    full.push(date.toLocaleDateString(locale, { weekday: 'long', timeZone: 'UTC' }))
+    short.push(date.toLocaleDateString(locale, { weekday: 'short', timeZone: 'UTC' }))
   }
-
   return { full, short }
 }
Suggestion importance[1-10]: 6

__

Why: Using a fixed UTC-anchored Sunday removes any year/timezone variability and is consistent with the tests’ expectations; the change is correct and improves determinism, though the original approach is generally acceptable.

Low
Generate months in fixed UTC year

Force UTC and a fixed year when generating month names to prevent timezone offsets
from shifting dates into adjacent months. This ensures stable month labeling
regardless of environment timezone/DST.

edge-apps/edge-apps-library/src/utils/locale.ts [193-212]

 export function getLocalizedMonthNames(locale: string): {
   full: string[]
   short: string[]
 } {
   const full: string[] = []
   const short: string[] = []
 
-  // Iterate through each month of the current year
-  const now = new Date()
-  const year = now.getFullYear()
-
+  // Use a fixed year and UTC to avoid TZ/DST boundary issues
+  const year = 2023
   for (let i = 0; i < 12; i++) {
-    const date = new Date(year, i, 1)
-
-    full.push(date.toLocaleDateString(locale, { month: 'long' }))
-    short.push(date.toLocaleDateString(locale, { month: 'short' }))
+    const date = new Date(Date.UTC(year, i, 1))
+    full.push(date.toLocaleDateString(locale, { month: 'long', timeZone: 'UTC' }))
+    short.push(date.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' }))
   }
 
   return { full, short }
 }
Suggestion importance[1-10]: 6

__

Why: Forcing a fixed UTC year avoids edge cases where local TZ/DST could shift dates, making outputs stable across environments. This is a sound improvement in reliability though not fixing a critical bug.

Low

Previous suggestions

Suggestions up to commit 07c9acf
CategorySuggestion                                                                                                                                    Impact
General
Prevent part overwrite in parsing

formatToParts may emit multiple parts with the same type (e.g., spaces or repeated
fields in some locales), and current logic overwrites prior values. Capture only the
first occurrence to avoid unexpected replacements and ensure stable extraction of
hour, minute, and second.

edge-apps/edge-apps-library/src/utils/formatting.ts [92-99]

 const parts = formatter.formatToParts(date)
 const partMap: Record<string, string> = {}
 
-parts.forEach((part) => {
-  if (part.type !== 'literal') {
+for (const part of parts) {
+  if (part.type !== 'literal' && partMap[part.type] === undefined) {
     partMap[part.type] = part.value
   }
-})
+}
Suggestion importance[1-10]: 7

__

Why: Valid concern: formatToParts can repeat types and current code overwrites values; capturing the first occurrence is a reasonable, low-risk improvement to ensure stable extraction.

Medium
Normalize all locale separators

Replacing only the first underscore can leave additional underscores in BCP 47 tags
(e.g., zh_Hans_CN). Replace all underscores to ensure proper normalization and
compatibility with Intl. This avoids locale parsing issues.

edge-apps/edge-apps-library/src/utils/locale.ts [55-57]

 if (overrideLocale) {
-  return overrideLocale.replace('_', '-')
+  return overrideLocale.replaceAll('_', '-')
 }
Suggestion importance[1-10]: 6

__

Why: Replacing all underscores improves BCP 47 normalization (e.g., zh_Hans_CN) and avoids Intl parsing issues; moderate impact and accurate change.

Low
Possible issue
Avoid locale-incorrect week start

Using Date#getDay() to compute week start assumes Sunday-first and ignores
locale-specific week starts (e.g., Monday in many regions). Derive day names
independently of week alignment by iterating fixed reference dates (e.g., 1970-01-04
+ i days) or use Intl.Locale weekInfo when available. This prevents misordered day
names for locales with different week starts.

edge-apps/edge-apps-library/src/utils/formatting.ts [21-22]

-const startOfWeek = new Date(now)
-startOfWeek.setDate(now.getDate() - now.getDay())
+const reference = new Date(Date.UTC(1970, 0, 4)) // Sunday, 1970-01-04 UTC
+const startOfWeek = new Date(reference)
Suggestion importance[1-10]: 5

__

Why: Correctly notes that using getDay() fixes Sunday as start-of-week and can misalign for locales with Monday-first; however, the proposed improved_code is incomplete (doesn't adjust iteration or usage), limiting impact.

Low

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements locale-aware utility functions in the edge-apps-library to centralize and standardize locale/timezone handling across Vue-based Edge Apps, reducing code duplication.

Key Changes:

  • Enhanced timezone and locale resolution with user override settings, validation, and robust fallback chains
  • Added comprehensive date/time formatting utilities including localized day/month names and intelligent hour format detection
  • Migrated from direct screenly global access to abstracted getMetadata() and getSettingWithDefault() functions

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 9 comments.

File Description
edge-apps/edge-apps-library/src/utils/locale.ts Refactored getTimeZone() and getLocale() to support override settings with validation, changed to async patterns, and improved error handling with fallback chains
edge-apps/edge-apps-library/src/utils/formatting.ts New module providing locale-aware formatting utilities: day/month name localization, hour format detection (12h/24h), and structured time formatting with timezone support

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

nicomiguelino and others added 5 commits December 24, 2025 07:54
- Create isValidLocale() to validate language codes match resolved locale
- Add locale override validation to getLocale() with fallback
- Use current year with fixed January 1st reference date in getLocalizedDayNames()
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
- Merge formatLocalizedDate, day names, month names, and time formatting into locale.ts
- Remove separate formatting.ts file
- Update index.ts exports
- Add 5 separate test files for locale utilities
- Test coverage for date, time, and day/month name formatting
- Support for en, de, ja, zh, fr, es, hi, th, ru locales
- Include Thai and Chinese numeral system tests
@nicomiguelino nicomiguelino marked this pull request as ready for review December 24, 2025 17:55
@github-actions
Copy link

Persistent review updated to latest commit c3ae600

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 9 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

nicomiguelino and others added 5 commits December 24, 2025 10:13
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
- Test valid override_timezone setting usage
- Test fallback to GPS when invalid override_timezone provided
- Test fallback to UTC when coordinates missing
- Test single underscore normalization (en_US → en-US)
- Test multiple underscore normalization (en_US_POSIX → en-US-POSIX)
- Test fallback to GPS detection for invalid override_locale
- Add regex validation for BCP 47 locale tag format
- Check that requested locale structure is preserved in resolution
- Catch malformed locales like 'en-', 'en-INVALID' early
- Add tests for malformed locale rejection
- Replace manual string concatenation with Intl.Locale API
- Properly merge numeral system extensions with existing locale extensions
- Handle locales like 'zh-CN-u-ca-chinese' without creating duplicate extensions
- Add graceful error handling and fallback to original locale
- Add test for locales with existing extensions
- Split locale test loops into individual parametrized test cases
- Each locale now runs as a separate test for better parallelization
- Improves test reporting clarity (13 → 26 day name tests, 12 → 19 month name tests)
- Tests can now run concurrently, reducing overall test suite time
- Add --parallel flag to test scripts in package.json
- All test suites now run concurrently
- Speeds up overall test execution time
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant