From 07c9acff1a5a2731ae593ceab7297efecc977ede Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Tue, 23 Dec 2025 17:59:15 -0800 Subject: [PATCH 01/14] feat: implement locale-aware utilities in edge-apps-library - 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 --- .../edge-apps-library/src/utils/formatting.ts | 167 ++++++++++++++++++ .../edge-apps-library/src/utils/locale.ts | 57 +++++- 2 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 edge-apps/edge-apps-library/src/utils/formatting.ts diff --git a/edge-apps/edge-apps-library/src/utils/formatting.ts b/edge-apps/edge-apps-library/src/utils/formatting.ts new file mode 100644 index 000000000..eaab7a4da --- /dev/null +++ b/edge-apps/edge-apps-library/src/utils/formatting.ts @@ -0,0 +1,167 @@ +// TODO: Add a utility function for formatting dates. +// Examples +// - "December 25, 2023" in en-US +// - "25 December 2023" in en-GB +// - "2023年12月25日" in ja-JP +// - "25.12.2023" in de-DE + +/** + * Get localized day names (Sunday-Saturday) + * Returns both full and short forms + */ +export function getLocalizedDayNames(locale: string): { + full: string[] + short: string[] +} { + const full: string[] = [] + const short: string[] = [] + + // Get current week and iterate through each day + const now = new Date() + const startOfWeek = new Date(now) + startOfWeek.setDate(now.getDate() - now.getDay()) + + for (let i = 0; i < 7; i++) { + const date = new Date(startOfWeek) + date.setDate(startOfWeek.getDate() + i) + + full.push(date.toLocaleDateString(locale, { weekday: 'long' })) + short.push(date.toLocaleDateString(locale, { weekday: 'short' })) + } + + return { full, short } +} + +/** + * Get localized month names (January-December) + * Returns both full and short forms + */ +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() + + 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' })) + } + + return { full, short } +} + +/** + * Detect if a locale uses 12-hour or 24-hour format + */ +export function detectHourFormat(locale: string): 'hour12' | 'hour24' { + try { + const formatter = new Intl.DateTimeFormat(locale, { + hour: '2-digit', + minute: '2-digit', + hour12: true, + }) + const parts = formatter.formatToParts(new Date()) + // If dayPeriod (AM/PM) exists, it's 12-hour format + const hasDayPeriod = parts.some((part) => part.type === 'dayPeriod') + return hasDayPeriod ? 'hour12' : 'hour24' + } catch { + // Fallback to 24-hour for unrecognized locales + return 'hour24' + } +} + +/** + * Extract time parts from a DateTimeFormat formatter + */ +function extractTimePartsFromFormatter( + date: Date, + formatter: Intl.DateTimeFormat, +): { + hour: string + minute: string + second: string + dayPeriod?: string +} { + const parts = formatter.formatToParts(date) + const partMap: Record = {} + + parts.forEach((part) => { + if (part.type !== 'literal') { + partMap[part.type] = part.value + } + }) + + return { + hour: partMap.hour || '00', + minute: partMap.minute || '00', + second: partMap.second || '00', + dayPeriod: partMap.dayPeriod, + } +} + +/** + * Format time with locale and timezone awareness + * Returns structured time parts for flexible composition + */ +export function formatTime( + date: Date, + locale: string, + timezone: string, + options?: { + hour12?: boolean + }, +): { + hour: string + minute: string + second: string + dayPeriod?: string + formatted: string +} { + try { + // Determine hour format if not explicitly provided + const hour12 = options?.hour12 ?? detectHourFormat(locale) === 'hour12' + + // Format with Intl API for proper localization + const formatter = new Intl.DateTimeFormat(locale, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12, + timeZone: timezone, + }) + + const timeParts = extractTimePartsFromFormatter(date, formatter) + + return { + ...timeParts, + formatted: formatter.format(date), + } + } catch (error) { + console.warn( + `Failed to format time for locale "${locale}" and timezone "${timezone}":`, + error, + ) + // Fallback to UTC in English + const fallbackFormatter = new Intl.DateTimeFormat('en', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + timeZone: 'UTC', + }) + + const timeParts = extractTimePartsFromFormatter(date, fallbackFormatter) + + return { + ...timeParts, + formatted: fallbackFormatter.format(date), + } + } +} diff --git a/edge-apps/edge-apps-library/src/utils/locale.ts b/edge-apps/edge-apps-library/src/utils/locale.ts index 2deadad1c..4c53f7093 100644 --- a/edge-apps/edge-apps-library/src/utils/locale.ts +++ b/edge-apps/edge-apps-library/src/utils/locale.ts @@ -1,23 +1,62 @@ import tzlookup from '@photostructure/tz-lookup' import clm from 'country-locale-map' import { getNearestCity } from 'offline-geocode-city' +import { getSettingWithDefault } from './settings.js' +import { getMetadata } from './metadata.js' /** - * Get the timezone for the screen's location - * Uses the GPS coordinates from Screenly metadata + * Validate timezone using native Intl API */ -export function getTimeZone(): string { - const [latitude, longitude] = screenly.metadata.coordinates - return tzlookup(latitude, longitude) +function isValidTimezone(timezone: string): boolean { + try { + new Intl.DateTimeFormat('en-US', { timeZone: timezone }) + return true + } catch { + return false + } } /** - * Get the locale for the screen's location - * Uses the GPS coordinates to determine the country and returns the appropriate locale - * Falls back to browser locale if geocoding fails + * Resolve timezone configuration with fallback chain + * Fallback order: override setting (validated) → GPS-based detection → 'UTC' + */ +export async function getTimeZone(): Promise { + // Priority 1: Use override setting if provided and valid + const overrideTimezone = getSettingWithDefault( + '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`, + ) + } + + try { + const [latitude, longitude] = getMetadata().coordinates + return tzlookup(latitude, longitude) + } catch (error) { + console.warn('Failed to get timezone from coordinates, using UTC:', error) + return 'UTC' + } +} + +/** + * Resolve locale configuration with fallback chain + * Fallback order: override setting → GPS-based detection → browser locale → 'en' */ export async function getLocale(): Promise { - const [lat, lng] = screenly.metadata.coordinates + // Priority 1: Use override setting if provided + const overrideLocale = getSettingWithDefault('override_locale', '') + if (overrideLocale) { + return overrideLocale.replace('_', '-') + } + + const [lat, lng] = getMetadata().coordinates const defaultLocale = (navigator?.languages?.length From dd725b9f841dbdcf114884d2fe9c0c2c5f9197a6 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Wed, 24 Dec 2025 07:54:36 -0800 Subject: [PATCH 02/14] refactor: improve locale validation and localized day names - 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() --- .../edge-apps-library/src/utils/formatting.ts | 19 +++++------- .../edge-apps-library/src/utils/index.ts | 1 + .../edge-apps-library/src/utils/locale.ts | 31 +++++++++++++++++-- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/edge-apps/edge-apps-library/src/utils/formatting.ts b/edge-apps/edge-apps-library/src/utils/formatting.ts index eaab7a4da..7449a80bf 100644 --- a/edge-apps/edge-apps-library/src/utils/formatting.ts +++ b/edge-apps/edge-apps-library/src/utils/formatting.ts @@ -16,14 +16,13 @@ export function getLocalizedDayNames(locale: string): { const full: string[] = [] const short: string[] = [] - // Get current week and iterate through each day + // Use a fixed reference date (January 1st) of the current year const now = new Date() - const startOfWeek = new Date(now) - startOfWeek.setDate(now.getDate() - now.getDay()) + const referenceDate = new Date(Date.UTC(now.getFullYear(), 0, 1)) for (let i = 0; i < 7; i++) { - const date = new Date(startOfWeek) - date.setDate(startOfWeek.getDate() + i) + const date = new Date(referenceDate) + date.setUTCDate(referenceDate.getUTCDate() + i) full.push(date.toLocaleDateString(locale, { weekday: 'long' })) short.push(date.toLocaleDateString(locale, { weekday: 'short' })) @@ -63,14 +62,10 @@ export function getLocalizedMonthNames(locale: string): { export function detectHourFormat(locale: string): 'hour12' | 'hour24' { try { const formatter = new Intl.DateTimeFormat(locale, { - hour: '2-digit', - minute: '2-digit', - hour12: true, + hour: 'numeric', }) - const parts = formatter.formatToParts(new Date()) - // If dayPeriod (AM/PM) exists, it's 12-hour format - const hasDayPeriod = parts.some((part) => part.type === 'dayPeriod') - return hasDayPeriod ? 'hour12' : 'hour24' + + return formatter.resolvedOptions().hour12 ? 'hour12' : 'hour24' } catch { // Fallback to 24-hour for unrecognized locales return 'hour24' diff --git a/edge-apps/edge-apps-library/src/utils/index.ts b/edge-apps/edge-apps-library/src/utils/index.ts index 89cd31879..df44d567c 100644 --- a/edge-apps/edge-apps-library/src/utils/index.ts +++ b/edge-apps/edge-apps-library/src/utils/index.ts @@ -3,3 +3,4 @@ export * from './locale.js' export * from './metadata.js' export * from './settings.js' export * from './utm.js' +export * from './formatting.js' diff --git a/edge-apps/edge-apps-library/src/utils/locale.ts b/edge-apps/edge-apps-library/src/utils/locale.ts index 4c53f7093..2b807f47b 100644 --- a/edge-apps/edge-apps-library/src/utils/locale.ts +++ b/edge-apps/edge-apps-library/src/utils/locale.ts @@ -16,6 +16,24 @@ function isValidTimezone(timezone: string): boolean { } } +/** + * Validate locale using native Intl API + * Ensures the resolved locale's language code matches the requested locale + */ +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 + } +} + /** * Resolve timezone configuration with fallback chain * Fallback order: override setting (validated) → GPS-based detection → 'UTC' @@ -47,13 +65,20 @@ export async function getTimeZone(): Promise { /** * Resolve locale configuration with fallback chain - * Fallback order: override setting → GPS-based detection → browser locale → 'en' + * Fallback order: override setting (validated) → GPS-based detection → browser locale → 'en' */ export async function getLocale(): Promise { - // Priority 1: Use override setting if provided + // Priority 1: Use override setting if provided and valid const overrideLocale = getSettingWithDefault('override_locale', '') if (overrideLocale) { - return overrideLocale.replace('_', '-') + const normalizedLocale = overrideLocale.replace('_', '-') + // Validate the override locale + if (isValidLocale(normalizedLocale)) { + return normalizedLocale + } + console.warn( + `Invalid locale override: "${overrideLocale}", falling back to GPS detection`, + ) } const [lat, lng] = getMetadata().coordinates From cc4d8b2ef797b8f395603a41382460d326e1af72 Mon Sep 17 00:00:00 2001 From: Nico Miguelino Date: Wed, 24 Dec 2025 07:59:38 -0800 Subject: [PATCH 03/14] feat(edge-apps-library): format a date in a locale-aware way Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../edge-apps-library/src/utils/formatting.ts | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/edge-apps/edge-apps-library/src/utils/formatting.ts b/edge-apps/edge-apps-library/src/utils/formatting.ts index 7449a80bf..308baba46 100644 --- a/edge-apps/edge-apps-library/src/utils/formatting.ts +++ b/edge-apps/edge-apps-library/src/utils/formatting.ts @@ -1,10 +1,41 @@ -// TODO: Add a utility function for formatting dates. -// Examples -// - "December 25, 2023" in en-US -// - "25 December 2023" in en-GB -// - "2023年12月25日" in ja-JP -// - "25.12.2023" in de-DE +/** + * Format a date in a locale-aware way. + * + * Examples: + * - "December 25, 2023" in en-US + * - "25 December 2023" in en-GB + * - "2023年12月25日" in ja-JP + * - "25.12.2023" in de-DE + * + * By default, formats as a full date (year, month, day). Callers can + * override or extend the formatting via the `options` parameter. + */ +export function formatLocalizedDate( + date: Date, + locale: string, + options?: Intl.DateTimeFormatOptions, +): string { + const baseOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + } + try { + const formatter = new Intl.DateTimeFormat(locale, { + ...baseOptions, + ...options, + }) + return formatter.format(date) + } catch { + // Fallback to a safe default for unrecognized locales + const fallbackFormatter = new Intl.DateTimeFormat('en-US', { + ...baseOptions, + ...options, + }) + return fallbackFormatter.format(date) + } +} /** * Get localized day names (Sunday-Saturday) * Returns both full and short forms From 218ff0b2bb2de42123d6a9031513b24ee025c0f2 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Wed, 24 Dec 2025 08:04:26 -0800 Subject: [PATCH 04/14] refactor: consolidate formatting utilities into locale.ts - Merge formatLocalizedDate, day names, month names, and time formatting into locale.ts - Remove separate formatting.ts file - Update index.ts exports --- .../edge-apps-library/src/utils/formatting.ts | 193 ----------------- .../edge-apps-library/src/utils/index.ts | 1 - .../edge-apps-library/src/utils/locale.ts | 195 ++++++++++++++++++ 3 files changed, 195 insertions(+), 194 deletions(-) delete mode 100644 edge-apps/edge-apps-library/src/utils/formatting.ts diff --git a/edge-apps/edge-apps-library/src/utils/formatting.ts b/edge-apps/edge-apps-library/src/utils/formatting.ts deleted file mode 100644 index 308baba46..000000000 --- a/edge-apps/edge-apps-library/src/utils/formatting.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Format a date in a locale-aware way. - * - * Examples: - * - "December 25, 2023" in en-US - * - "25 December 2023" in en-GB - * - "2023年12月25日" in ja-JP - * - "25.12.2023" in de-DE - * - * By default, formats as a full date (year, month, day). Callers can - * override or extend the formatting via the `options` parameter. - */ -export function formatLocalizedDate( - date: Date, - locale: string, - options?: Intl.DateTimeFormatOptions, -): string { - const baseOptions: Intl.DateTimeFormatOptions = { - year: 'numeric', - month: 'long', - day: 'numeric', - } - - try { - const formatter = new Intl.DateTimeFormat(locale, { - ...baseOptions, - ...options, - }) - return formatter.format(date) - } catch { - // Fallback to a safe default for unrecognized locales - const fallbackFormatter = new Intl.DateTimeFormat('en-US', { - ...baseOptions, - ...options, - }) - return fallbackFormatter.format(date) - } -} -/** - * Get localized day names (Sunday-Saturday) - * Returns both full and short forms - */ -export function getLocalizedDayNames(locale: string): { - full: string[] - short: string[] -} { - const full: string[] = [] - const short: string[] = [] - - // Use a fixed reference date (January 1st) of the current year - const now = new Date() - const referenceDate = new Date(Date.UTC(now.getFullYear(), 0, 1)) - - for (let i = 0; i < 7; i++) { - const date = new Date(referenceDate) - date.setUTCDate(referenceDate.getUTCDate() + i) - - full.push(date.toLocaleDateString(locale, { weekday: 'long' })) - short.push(date.toLocaleDateString(locale, { weekday: 'short' })) - } - - return { full, short } -} - -/** - * Get localized month names (January-December) - * Returns both full and short forms - */ -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() - - 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' })) - } - - return { full, short } -} - -/** - * Detect if a locale uses 12-hour or 24-hour format - */ -export function detectHourFormat(locale: string): 'hour12' | 'hour24' { - try { - const formatter = new Intl.DateTimeFormat(locale, { - hour: 'numeric', - }) - - return formatter.resolvedOptions().hour12 ? 'hour12' : 'hour24' - } catch { - // Fallback to 24-hour for unrecognized locales - return 'hour24' - } -} - -/** - * Extract time parts from a DateTimeFormat formatter - */ -function extractTimePartsFromFormatter( - date: Date, - formatter: Intl.DateTimeFormat, -): { - hour: string - minute: string - second: string - dayPeriod?: string -} { - const parts = formatter.formatToParts(date) - const partMap: Record = {} - - parts.forEach((part) => { - if (part.type !== 'literal') { - partMap[part.type] = part.value - } - }) - - return { - hour: partMap.hour || '00', - minute: partMap.minute || '00', - second: partMap.second || '00', - dayPeriod: partMap.dayPeriod, - } -} - -/** - * Format time with locale and timezone awareness - * Returns structured time parts for flexible composition - */ -export function formatTime( - date: Date, - locale: string, - timezone: string, - options?: { - hour12?: boolean - }, -): { - hour: string - minute: string - second: string - dayPeriod?: string - formatted: string -} { - try { - // Determine hour format if not explicitly provided - const hour12 = options?.hour12 ?? detectHourFormat(locale) === 'hour12' - - // Format with Intl API for proper localization - const formatter = new Intl.DateTimeFormat(locale, { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12, - timeZone: timezone, - }) - - const timeParts = extractTimePartsFromFormatter(date, formatter) - - return { - ...timeParts, - formatted: formatter.format(date), - } - } catch (error) { - console.warn( - `Failed to format time for locale "${locale}" and timezone "${timezone}":`, - error, - ) - // Fallback to UTC in English - const fallbackFormatter = new Intl.DateTimeFormat('en', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, - timeZone: 'UTC', - }) - - const timeParts = extractTimePartsFromFormatter(date, fallbackFormatter) - - return { - ...timeParts, - formatted: fallbackFormatter.format(date), - } - } -} diff --git a/edge-apps/edge-apps-library/src/utils/index.ts b/edge-apps/edge-apps-library/src/utils/index.ts index df44d567c..89cd31879 100644 --- a/edge-apps/edge-apps-library/src/utils/index.ts +++ b/edge-apps/edge-apps-library/src/utils/index.ts @@ -3,4 +3,3 @@ export * from './locale.js' export * from './metadata.js' export * from './settings.js' export * from './utm.js' -export * from './formatting.js' diff --git a/edge-apps/edge-apps-library/src/utils/locale.ts b/edge-apps/edge-apps-library/src/utils/locale.ts index 2b807f47b..6a5c47fc2 100644 --- a/edge-apps/edge-apps-library/src/utils/locale.ts +++ b/edge-apps/edge-apps-library/src/utils/locale.ts @@ -114,3 +114,198 @@ export function formatCoordinates(coordinates: [number, number]): string { return `${latString} ${latDirection}, ${lngString} ${lngDirection}` } + +/** + * Format a date in a locale-aware way. + * + * Examples: + * - "December 25, 2023" in en-US + * - "25 December 2023" in en-GB + * - "2023年12月25日" in ja-JP + * - "25.12.2023" in de-DE + * + * By default, formats as a full date (year, month, day). Callers can + * override or extend the formatting via the `options` parameter. + */ +export function formatLocalizedDate( + date: Date, + locale: string, + options?: Intl.DateTimeFormatOptions, +): string { + const baseOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + } + + try { + const formatter = new Intl.DateTimeFormat(locale, { + ...baseOptions, + ...options, + }) + return formatter.format(date) + } catch { + // Fallback to a safe default for unrecognized locales + const fallbackFormatter = new Intl.DateTimeFormat('en-US', { + ...baseOptions, + ...options, + }) + return fallbackFormatter.format(date) + } +} + +/** + * Get localized day names (Sunday-Saturday) + * Returns both full and short forms + */ +export function getLocalizedDayNames(locale: string): { + full: string[] + short: string[] +} { + const full: string[] = [] + const short: string[] = [] + + // Use a fixed reference date (January 1st) of the current year + const now = new Date() + const referenceDate = new Date(Date.UTC(now.getFullYear(), 0, 1)) + + for (let i = 0; i < 7; i++) { + const date = new Date(referenceDate) + date.setUTCDate(referenceDate.getUTCDate() + i) + + full.push(date.toLocaleDateString(locale, { weekday: 'long' })) + short.push(date.toLocaleDateString(locale, { weekday: 'short' })) + } + + return { full, short } +} + +/** + * Get localized month names (January-December) + * Returns both full and short forms + */ +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() + + 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' })) + } + + return { full, short } +} + +/** + * Detect if a locale uses 12-hour or 24-hour format + */ +export function detectHourFormat(locale: string): 'hour12' | 'hour24' { + try { + const formatter = new Intl.DateTimeFormat(locale, { + hour: 'numeric', + }) + + return formatter.resolvedOptions().hour12 ? 'hour12' : 'hour24' + } catch { + // Fallback to 24-hour for unrecognized locales + return 'hour24' + } +} + +/** + * Extract time parts from a DateTimeFormat formatter + */ +function extractTimePartsFromFormatter( + date: Date, + formatter: Intl.DateTimeFormat, +): { + hour: string + minute: string + second: string + dayPeriod?: string +} { + const parts = formatter.formatToParts(date) + const partMap: Record = {} + + parts.forEach((part) => { + if (part.type !== 'literal') { + partMap[part.type] = part.value + } + }) + + return { + hour: partMap.hour || '00', + minute: partMap.minute || '00', + second: partMap.second || '00', + dayPeriod: partMap.dayPeriod, + } +} + +/** + * Format time with locale and timezone awareness + * Returns structured time parts for flexible composition + */ +export function formatTime( + date: Date, + locale: string, + timezone: string, + options?: { + hour12?: boolean + }, +): { + hour: string + minute: string + second: string + dayPeriod?: string + formatted: string +} { + try { + // Determine hour format if not explicitly provided + const hour12 = options?.hour12 ?? detectHourFormat(locale) === 'hour12' + + // Format with Intl API for proper localization + const formatter = new Intl.DateTimeFormat(locale, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12, + timeZone: timezone, + }) + + const timeParts = extractTimePartsFromFormatter(date, formatter) + + return { + ...timeParts, + formatted: formatter.format(date), + } + } catch (error) { + console.warn( + `Failed to format time for locale "${locale}" and timezone "${timezone}":`, + error, + ) + // Fallback to UTC in English + const fallbackFormatter = new Intl.DateTimeFormat('en', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + timeZone: 'UTC', + }) + + const timeParts = extractTimePartsFromFormatter(date, fallbackFormatter) + + return { + ...timeParts, + formatted: fallbackFormatter.format(date), + } + } +} From e957d2bb6a7187c6edd284e84fb9a4abfcd21331 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Wed, 24 Dec 2025 08:15:51 -0800 Subject: [PATCH 05/14] chore(edge-apps-library): make time zone test cases async --- edge-apps/edge-apps-library/src/utils/locale.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/edge-apps/edge-apps-library/src/utils/locale.test.ts b/edge-apps/edge-apps-library/src/utils/locale.test.ts index 9e20f8441..a2984bde6 100644 --- a/edge-apps/edge-apps-library/src/utils/locale.test.ts +++ b/edge-apps/edge-apps-library/src/utils/locale.test.ts @@ -12,30 +12,30 @@ describe('locale utilities', () => { }) describe('getTimeZone', () => { - test('should return timezone for coordinates', () => { + test('should return timezone for coordinates', async () => { setupScreenlyMock({ coordinates: [37.3861, -122.0839], // Mountain View, CA }) - const timezone = getTimeZone() + const timezone = await getTimeZone() expect(timezone).toBe('America/Los_Angeles') }) - test('should return timezone for London coordinates', () => { + test('should return timezone for London coordinates', async () => { setupScreenlyMock({ coordinates: [51.5074, -0.1278], // London, UK }) - const timezone = getTimeZone() + const timezone = await getTimeZone() expect(timezone).toBe('Europe/London') }) - test('should return timezone for Tokyo coordinates', () => { + test('should return timezone for Tokyo coordinates', async () => { setupScreenlyMock({ coordinates: [35.6762, 139.6503], // Tokyo, Japan }) - const timezone = getTimeZone() + const timezone = await getTimeZone() expect(timezone).toBe('Asia/Tokyo') }) }) From 9051486609166e1b7a5b433b6c0bd2ce46ef4404 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Wed, 24 Dec 2025 09:50:24 -0800 Subject: [PATCH 06/14] test: add comprehensive locale utility tests - 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 --- .../src/utils/detect-hour-format.test.ts | 54 +++ .../src/utils/format-localized-date.test.ts | 73 ++++ .../src/utils/format-time.test.ts | 136 +++++++ .../src/utils/get-localized-day-names.test.ts | 212 +++++++++++ .../utils/get-localized-month-names.test.ts | 360 ++++++++++++++++++ .../edge-apps-library/src/utils/locale.ts | 41 +- 6 files changed, 871 insertions(+), 5 deletions(-) create mode 100644 edge-apps/edge-apps-library/src/utils/detect-hour-format.test.ts create mode 100644 edge-apps/edge-apps-library/src/utils/format-localized-date.test.ts create mode 100644 edge-apps/edge-apps-library/src/utils/format-time.test.ts create mode 100644 edge-apps/edge-apps-library/src/utils/get-localized-day-names.test.ts create mode 100644 edge-apps/edge-apps-library/src/utils/get-localized-month-names.test.ts diff --git a/edge-apps/edge-apps-library/src/utils/detect-hour-format.test.ts b/edge-apps/edge-apps-library/src/utils/detect-hour-format.test.ts new file mode 100644 index 000000000..d19947e80 --- /dev/null +++ b/edge-apps/edge-apps-library/src/utils/detect-hour-format.test.ts @@ -0,0 +1,54 @@ +import { describe, test, expect } from 'bun:test' +import { detectHourFormat } from './locale' + +describe('detectHourFormat', () => { + test('should return hour12 for US locales', () => { + const locales = ['en-US', 'en'] + for (const locale of locales) { + const format = detectHourFormat(locale) + expect(format).toBe('hour12') + } + }) + + test('should return hour24 for most European locales', () => { + const locales = [ + 'en-GB', + 'de-DE', + 'de', + 'fr-FR', + 'fr', + 'es-ES', + 'es', + 'ru-RU', + 'ru', + ] + for (const locale of locales) { + const format = detectHourFormat(locale) + expect(format).toBe('hour24') + } + }) + + test('should return hour24 for some Asian locales', () => { + const locales = ['ja-JP', 'ja', 'zh-CN', 'zh'] + for (const locale of locales) { + const format = detectHourFormat(locale) + expect(format).toBe('hour24') + } + }) + + test('should return hour24 for Thailand locale', () => { + const locales = ['th-TH', 'th'] + for (const locale of locales) { + const format = detectHourFormat(locale) + expect(format).toBe('hour24') + } + }) + + test('should return hour12 for India locale', () => { + const locales = ['hi-IN', 'hi'] + for (const locale of locales) { + const format = detectHourFormat(locale) + expect(format).toBe('hour12') + } + }) +}) diff --git a/edge-apps/edge-apps-library/src/utils/format-localized-date.test.ts b/edge-apps/edge-apps-library/src/utils/format-localized-date.test.ts new file mode 100644 index 000000000..331586e9f --- /dev/null +++ b/edge-apps/edge-apps-library/src/utils/format-localized-date.test.ts @@ -0,0 +1,73 @@ +import { describe, test, expect } from 'bun:test' +import { formatLocalizedDate } from './locale' + +describe('formatLocalizedDate', () => { + const testDate = new Date(2023, 11, 25) // December 25, 2023 + + test('should format date correctly for different Latin locales', () => { + const locales = [ + { locale: 'en-US', expected: 'December 25, 2023' }, + { locale: 'en-GB', expected: '25 December 2023' }, + { locale: 'de-DE', expected: '25. Dezember 2023' }, + { locale: 'ru-RU', expected: '25 декабря 2023 г.' }, + ] + + for (const { locale, expected } of locales) { + const formatted = formatLocalizedDate(testDate, locale) + expect(formatted).toBe(expected) + } + }) + + test('should format date correctly for non-Latin locales', () => { + const nonLatinLocales = [ + { locale: 'ja-JP', expected: '2023年12月25日' }, + { locale: 'zh-CN', expected: '2023年12月25日' }, + { locale: 'th-TH', expected: '25 ธันวาคม 2566' }, + { locale: 'hi-IN', expected: '25 दिसंबर 2023' }, + ] + + for (const { locale, expected } of nonLatinLocales) { + const formatted = formatLocalizedDate(testDate, locale) + expect(formatted).toBe(expected) + } + }) + + test('should format date correctly with shorthand locale notations', () => { + const shorthandLocales = [ + { locale: 'en', expected: 'Dec 25, 2023' }, + { locale: 'en-US', expected: 'Dec 25, 2023' }, + { locale: 'en-GB', expected: '25 Dec 2023' }, + { locale: 'de', expected: '25. Dez. 2023' }, + { locale: 'de-DE', expected: '25. Dez. 2023' }, + { locale: 'ja', expected: '2023年12月25日' }, + { locale: 'ja-JP', expected: '2023年12月25日' }, + { locale: 'zh', expected: '2023年12月25日' }, + { locale: 'zh-CN', expected: '2023年12月25日' }, + { locale: 'th', expected: '25 ธ.ค. 2566' }, + { locale: 'th-TH', expected: '25 ธ.ค. 2566' }, + { locale: 'hi', expected: '25 दिस॰ 2023' }, + { locale: 'hi-IN', expected: '25 दिस॰ 2023' }, + ] + + for (const { locale, expected } of shorthandLocales) { + const formatted = formatLocalizedDate(testDate, locale, { + month: 'short', + }) + expect(formatted).toBe(expected) + } + }) + + test('should respect custom options parameter', () => { + const formatted = formatLocalizedDate(testDate, 'en-US', { + year: '2-digit', + month: 'short', + day: '2-digit', + }) + expect(formatted).toBe('Dec 25, 23') + }) + + test('should fallback to en-US for invalid locale', () => { + const formatted = formatLocalizedDate(testDate, 'invalid-locale') + expect(formatted).toBe('December 25, 2023') + }) +}) diff --git a/edge-apps/edge-apps-library/src/utils/format-time.test.ts b/edge-apps/edge-apps-library/src/utils/format-time.test.ts new file mode 100644 index 000000000..194d56509 --- /dev/null +++ b/edge-apps/edge-apps-library/src/utils/format-time.test.ts @@ -0,0 +1,136 @@ +import { describe, test, expect } from 'bun:test' +import { formatTime } from './locale' + +describe('formatTime', () => { + const testDate = new Date('2023-12-25T14:30:45Z') + + test('should format time correctly for en-US locale with hour12', () => { + const result = formatTime(testDate, 'en-US', 'UTC', { hour12: true }) + expect(result).toHaveProperty('hour') + expect(result).toHaveProperty('minute') + expect(result).toHaveProperty('second') + expect(result).toHaveProperty('dayPeriod') + expect(result).toHaveProperty('formatted') + expect(result.hour).toBe('02') + expect(result.minute).toBe('30') + expect(result.second).toBe('45') + expect(result.dayPeriod).toBe('PM') + expect(result.formatted).toBe('02:30:45 PM') + }) + + test('should format time correctly for en-US locale with hour24', () => { + const result = formatTime(testDate, 'en-US', 'UTC', { hour12: false }) + expect(result.hour).toBe('14') + expect(result.minute).toBe('30') + expect(result.second).toBe('45') + expect(result.dayPeriod).toBeUndefined() + expect(result.formatted).toBe('14:30:45') + }) + + test('should format time correctly for de-DE locale (24-hour)', () => { + const result = formatTime(testDate, 'de-DE', 'UTC') + expect(result.hour).toBe('14') + expect(result.minute).toBe('30') + expect(result.second).toBe('45') + expect(result.dayPeriod).toBeUndefined() + expect(result.formatted).toBe('14:30:45') + }) + + test('should format time correctly for ja-JP locale (24-hour)', () => { + const result = formatTime(testDate, 'ja-JP', 'UTC') + expect(result.hour).toBe('14') + expect(result.minute).toBe('30') + expect(result.second).toBe('45') + expect(result.dayPeriod).toBeUndefined() + expect(result.formatted).toBe('14:30:45') + }) + + test('should format time correctly with different timezones', () => { + const result = formatTime(testDate, 'en-US', 'America/Los_Angeles', { + hour12: true, + }) + expect(result).toHaveProperty('hour') + expect(result).toHaveProperty('minute') + expect(result).toHaveProperty('second') + expect(result.minute).toBe('30') + expect(result.second).toBe('45') + // Los Angeles is UTC-8 in December, so 14:30 UTC becomes 06:30 AM + expect(result.hour).toBe('06') + expect(result.dayPeriod).toBe('AM') + expect(result.formatted).toBe('06:30:45 AM') + }) + + test('should format time correctly with Tokyo timezone', () => { + const result = formatTime(testDate, 'ja-JP', 'Asia/Tokyo') + expect(result.minute).toBe('30') + expect(result.second).toBe('45') + // Tokyo is UTC+9, so 14:30 UTC becomes 23:30 + expect(result.hour).toBe('23') + expect(result.formatted).toBe('23:30:45') + }) + + test('should format time correctly for hi-IN locale (hour12)', () => { + const result = formatTime(testDate, 'hi-IN', 'UTC') + expect(result.hour).toBe('02') + expect(result.minute).toBe('30') + expect(result.second).toBe('45') + expect(result.dayPeriod).toBe('pm') + expect(result.formatted).toMatch(/02:30:45\s+pm/) + }) + + 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('๑๔:๓๐:๔๕') + }) + + test('should format time correctly for ru-RU locale (24-hour)', () => { + const result = formatTime(testDate, 'ru-RU', 'UTC') + expect(result.hour).toBe('14') + expect(result.minute).toBe('30') + expect(result.second).toBe('45') + expect(result.dayPeriod).toBeUndefined() + expect(result.formatted).toBe('14:30:45') + }) + + test('should return formatted string property', () => { + const result = formatTime(testDate, 'en-US', 'UTC', { hour12: true }) + expect(result.formatted).toBeDefined() + expect(typeof result.formatted).toBe('string') + // Verify formatted contains the time components + expect(result.formatted).toContain('02') + expect(result.formatted).toContain('30') + expect(result.formatted).toContain('45') + expect(result.formatted).toContain('PM') + expect(result.formatted).toMatch(/02:30:45/) + expect(result.formatted).toBe('02:30:45 PM') + }) + + test('should handle invalid timezone gracefully with fallback', () => { + const originalWarn = console.warn + console.warn = () => {} // Suppress expected warnings + try { + const result = formatTime(testDate, 'en-US', 'Invalid/Timezone') + expect(result).toHaveProperty('hour') + expect(result).toHaveProperty('minute') + expect(result).toHaveProperty('second') + expect(result).toHaveProperty('formatted') + } finally { + console.warn = originalWarn + } + }) +}) diff --git a/edge-apps/edge-apps-library/src/utils/get-localized-day-names.test.ts b/edge-apps/edge-apps-library/src/utils/get-localized-day-names.test.ts new file mode 100644 index 000000000..e2aa8dd00 --- /dev/null +++ b/edge-apps/edge-apps-library/src/utils/get-localized-day-names.test.ts @@ -0,0 +1,212 @@ +import { describe, test, expect } from 'bun:test' +import { getLocalizedDayNames } from './locale' + +describe('getLocalizedDayNames', () => { + test('should return full and short day names for en-US locale', () => { + const result = getLocalizedDayNames('en-US') + expect(result).toHaveProperty('full') + expect(result).toHaveProperty('short') + expect(result.full).toHaveLength(7) + expect(result.short).toHaveLength(7) + expect(result.full).toEqual([ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ]) + expect(result.short).toEqual([ + 'Sun', + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + ]) + }) + + test('should return localized longhand day names for different locales', () => { + const localeExpectations: Array<{ + locale: string + full: string[] + }> = [ + { + locale: 'en-GB', + full: [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ], + }, + { + locale: 'de-DE', + full: [ + 'Sonntag', + 'Montag', + 'Dienstag', + 'Mittwoch', + 'Donnerstag', + 'Freitag', + 'Samstag', + ], + }, + { + locale: 'ja-JP', + full: [ + '日曜日', + '月曜日', + '火曜日', + '水曜日', + '木曜日', + '金曜日', + '土曜日', + ], + }, + { + locale: 'fr-FR', + full: [ + 'dimanche', + 'lundi', + 'mardi', + 'mercredi', + 'jeudi', + 'vendredi', + 'samedi', + ], + }, + { + locale: 'es-ES', + full: [ + 'domingo', + 'lunes', + 'martes', + 'miércoles', + 'jueves', + 'viernes', + 'sábado', + ], + }, + { + locale: 'ru-RU', + full: [ + 'воскресенье', + 'понедельник', + 'вторник', + 'среда', + 'четверг', + 'пятница', + 'суббота', + ], + }, + ] + + for (const { locale, full: expectedFull } of localeExpectations) { + const result = getLocalizedDayNames(locale) + expect(result.full).toEqual(expectedFull) + } + }) + + test('should return day names for shorthand day names for different locales', () => { + const localeExpectations: Array<{ + locale: string + full: string[] + short: string[] + }> = [ + { + locale: 'en', + full: [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ], + short: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + }, + { + locale: 'de', + full: [ + 'Sonntag', + 'Montag', + 'Dienstag', + 'Mittwoch', + 'Donnerstag', + 'Freitag', + 'Samstag', + ], + short: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'], + }, + { + locale: 'ja', + full: [ + '日曜日', + '月曜日', + '火曜日', + '水曜日', + '木曜日', + '金曜日', + '土曜日', + ], + short: ['日', '月', '火', '水', '木', '金', '土'], + }, + { + locale: 'fr', + full: [ + 'dimanche', + 'lundi', + 'mardi', + 'mercredi', + 'jeudi', + 'vendredi', + 'samedi', + ], + short: ['dim.', 'lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.'], + }, + { + locale: 'es', + full: [ + 'domingo', + 'lunes', + 'martes', + 'miércoles', + 'jueves', + 'viernes', + 'sábado', + ], + short: ['dom', 'lun', 'mar', 'mié', 'jue', 'vie', 'sáb'], + }, + { + locale: 'ru', + full: [ + 'воскресенье', + 'понедельник', + 'вторник', + 'среда', + 'четверг', + 'пятница', + 'суббота', + ], + short: ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'], + }, + ] + + for (const { + locale, + full: expectedFull, + short: expectedShort, + } of localeExpectations) { + const result = getLocalizedDayNames(locale) + expect(result.full).toEqual(expectedFull) + expect(result.short).toEqual(expectedShort) + } + }) +}) diff --git a/edge-apps/edge-apps-library/src/utils/get-localized-month-names.test.ts b/edge-apps/edge-apps-library/src/utils/get-localized-month-names.test.ts new file mode 100644 index 000000000..797652c8f --- /dev/null +++ b/edge-apps/edge-apps-library/src/utils/get-localized-month-names.test.ts @@ -0,0 +1,360 @@ +import { describe, test, expect } from 'bun:test' +import { getLocalizedMonthNames } from './locale' + +describe('getLocalizedMonthNames', () => { + test('should return full and short month names for en-US locale', () => { + const result = getLocalizedMonthNames('en-US') + expect(result).toHaveProperty('full') + expect(result).toHaveProperty('short') + expect(result.full).toHaveLength(12) + expect(result.short).toHaveLength(12) + expect(result.full).toEqual([ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]) + expect(result.short).toEqual([ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]) + }) + + test('should return localized longhand month names for different locales', () => { + const localeExpectations: Array<{ + locale: string + full: string[] + }> = [ + { + locale: 'en-GB', + full: [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ], + }, + { + locale: 'de-DE', + full: [ + 'Januar', + 'Februar', + 'März', + 'April', + 'Mai', + 'Juni', + 'Juli', + 'August', + 'September', + 'Oktober', + 'November', + 'Dezember', + ], + }, + { + locale: 'ja-JP', + full: [ + '1月', + '2月', + '3月', + '4月', + '5月', + '6月', + '7月', + '8月', + '9月', + '10月', + '11月', + '12月', + ], + }, + { + locale: 'fr-FR', + full: [ + 'janvier', + 'février', + 'mars', + 'avril', + 'mai', + 'juin', + 'juillet', + 'août', + 'septembre', + 'octobre', + 'novembre', + 'décembre', + ], + }, + { + locale: 'es-ES', + full: [ + 'enero', + 'febrero', + 'marzo', + 'abril', + 'mayo', + 'junio', + 'julio', + 'agosto', + 'septiembre', + 'octubre', + 'noviembre', + 'diciembre', + ], + }, + { + locale: 'ru-RU', + full: [ + 'январь', + 'февраль', + 'март', + 'апрель', + 'май', + 'июнь', + 'июль', + 'август', + 'сентябрь', + 'октябрь', + 'ноябрь', + 'декабрь', + ], + }, + ] + + for (const { locale, full: expectedFull } of localeExpectations) { + const result = getLocalizedMonthNames(locale) + expect(result.full).toEqual(expectedFull) + } + }) + + test('should return month names for shorthand month names for different locales', () => { + const localeExpectations: Array<{ + locale: string + full: string[] + short: string[] + }> = [ + { + locale: 'en', + full: [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ], + short: [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ], + }, + { + locale: 'de', + full: [ + 'Januar', + 'Februar', + 'März', + 'April', + 'Mai', + 'Juni', + 'Juli', + 'August', + 'September', + 'Oktober', + 'November', + 'Dezember', + ], + short: [ + 'Jan', + 'Feb', + 'Mär', + 'Apr', + 'Mai', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Okt', + 'Nov', + 'Dez', + ], + }, + { + locale: 'ja', + full: [ + '1月', + '2月', + '3月', + '4月', + '5月', + '6月', + '7月', + '8月', + '9月', + '10月', + '11月', + '12月', + ], + short: [ + '1月', + '2月', + '3月', + '4月', + '5月', + '6月', + '7月', + '8月', + '9月', + '10月', + '11月', + '12月', + ], + }, + { + locale: 'fr', + full: [ + 'janvier', + 'février', + 'mars', + 'avril', + 'mai', + 'juin', + 'juillet', + 'août', + 'septembre', + 'octobre', + 'novembre', + 'décembre', + ], + short: [ + 'janv.', + 'févr.', + 'mars', + 'avr.', + 'mai', + 'juin', + 'juil.', + 'août', + 'sept.', + 'oct.', + 'nov.', + 'déc.', + ], + }, + { + locale: 'es', + full: [ + 'enero', + 'febrero', + 'marzo', + 'abril', + 'mayo', + 'junio', + 'julio', + 'agosto', + 'septiembre', + 'octubre', + 'noviembre', + 'diciembre', + ], + short: [ + 'ene', + 'feb', + 'mar', + 'abr', + 'may', + 'jun', + 'jul', + 'ago', + 'sept', + 'oct', + 'nov', + 'dic', + ], + }, + { + locale: 'ru', + full: [ + 'январь', + 'февраль', + 'март', + 'апрель', + 'май', + 'июнь', + 'июль', + 'август', + 'сентябрь', + 'октябрь', + 'ноябрь', + 'декабрь', + ], + short: [ + 'янв.', + 'февр.', + 'март', + 'апр.', + 'май', + 'июнь', + 'июль', + 'авг.', + 'сент.', + 'окт.', + 'нояб.', + 'дек.', + ], + }, + ] + + for (const { + locale, + full: expectedFull, + short: expectedShort, + } of localeExpectations) { + const result = getLocalizedMonthNames(locale) + expect(result.full).toEqual(expectedFull) + expect(result.short).toEqual(expectedShort) + } + }) +}) diff --git a/edge-apps/edge-apps-library/src/utils/locale.ts b/edge-apps/edge-apps-library/src/utils/locale.ts index 6a5c47fc2..e0c9307aa 100644 --- a/edge-apps/edge-apps-library/src/utils/locale.ts +++ b/edge-apps/edge-apps-library/src/utils/locale.ts @@ -165,13 +165,19 @@ export function getLocalizedDayNames(locale: string): { const full: string[] = [] const short: string[] = [] - // Use a fixed reference date (January 1st) of the current year + // Find the first Sunday of the current year const now = new Date() - const referenceDate = new Date(Date.UTC(now.getFullYear(), 0, 1)) + 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(referenceDate) - date.setUTCDate(referenceDate.getUTCDate() + 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' })) @@ -250,6 +256,28 @@ function extractTimePartsFromFormatter( } } +/** + * Get locale extension for numeral system based on language + * This enables locale-specific number representations (e.g., Thai numerals, Chinese numerals) + */ +function getLocaleWithNumeralSystem(locale: string): string { + const language = locale.toLowerCase().split('-')[0] + + // Map of languages to their numeral system extensions + const numeralSystemMap: Record = { + th: 'thai', // Thai numerals: ๐๑๒๓๔๕๖๗๘๙ + zh: 'hanidec', // Chinese numerals: 〇一二三四五六七八九 + } + + const numeralSystem = numeralSystemMap[language] + if (numeralSystem) { + // Add numeral system extension using Unicode extension + return `${locale}-u-nu-${numeralSystem}` + } + + return locale +} + /** * Format time with locale and timezone awareness * Returns structured time parts for flexible composition @@ -272,8 +300,11 @@ export function formatTime( // Determine hour format if not explicitly provided const hour12 = options?.hour12 ?? detectHourFormat(locale) === 'hour12' + // Get locale with numeral system extension if applicable + const localeWithNumerals = getLocaleWithNumeralSystem(locale) + // Format with Intl API for proper localization - const formatter = new Intl.DateTimeFormat(locale, { + const formatter = new Intl.DateTimeFormat(localeWithNumerals, { hour: '2-digit', minute: '2-digit', second: '2-digit', From c3ae600bb249d0c356e461a92d0810c9b741fefc Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Wed, 24 Dec 2025 09:54:24 -0800 Subject: [PATCH 07/14] docs: add locale utility functions to README --- edge-apps/edge-apps-library/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/edge-apps/edge-apps-library/README.md b/edge-apps/edge-apps-library/README.md index 6575850c7..2fec9045d 100644 --- a/edge-apps/edge-apps-library/README.md +++ b/edge-apps/edge-apps-library/README.md @@ -38,11 +38,16 @@ signalReady() - `getMetadata()` - Get all screen metadata - `getScreenName()`, `getHostname()`, `getLocation()`, `getHardware()`, `getScreenlyVersion()`, `getTags()`, `hasTag(tag)`, `getFormattedCoordinates()` -### Location +### Location & Localization - `getTimeZone()` - Get timezone from GPS coordinates - `getLocale()` - Get locale from location - `formatCoordinates(coords)` - Format coordinates as string +- `formatLocalizedDate(date, locale)` - Format date for locale +- `formatTime(date, locale, timezone)` - Format time for locale +- `getLocalizedDayNames(locale)` - Get day names for locale +- `getLocalizedMonthNames(locale)` - Get month names for locale +- `detectHourFormat(locale)` - Detect 12h/24h format for locale ### UTM Tracking From 482e0455948a1abe7d4fc09683b08bbd9885aa7b Mon Sep 17 00:00:00 2001 From: Nico Miguelino Date: Wed, 24 Dec 2025 10:13:49 -0800 Subject: [PATCH 08/14] test: rename redundant test case names Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../edge-apps-library/src/utils/get-localized-day-names.test.ts | 2 +- .../src/utils/get-localized-month-names.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/edge-apps/edge-apps-library/src/utils/get-localized-day-names.test.ts b/edge-apps/edge-apps-library/src/utils/get-localized-day-names.test.ts index e2aa8dd00..4ddf1f605 100644 --- a/edge-apps/edge-apps-library/src/utils/get-localized-day-names.test.ts +++ b/edge-apps/edge-apps-library/src/utils/get-localized-day-names.test.ts @@ -113,7 +113,7 @@ describe('getLocalizedDayNames', () => { } }) - test('should return day names for shorthand day names for different locales', () => { + test('should return full and shorthand day names for different locales', () => { const localeExpectations: Array<{ locale: string full: string[] diff --git a/edge-apps/edge-apps-library/src/utils/get-localized-month-names.test.ts b/edge-apps/edge-apps-library/src/utils/get-localized-month-names.test.ts index 797652c8f..a66d92d4e 100644 --- a/edge-apps/edge-apps-library/src/utils/get-localized-month-names.test.ts +++ b/edge-apps/edge-apps-library/src/utils/get-localized-month-names.test.ts @@ -153,7 +153,7 @@ describe('getLocalizedMonthNames', () => { } }) - test('should return month names for shorthand month names for different locales', () => { + test('should return full and short month names for different locales', () => { const localeExpectations: Array<{ locale: string full: string[] From 53a4632ec9deed0cbe9e601f6bf0e8b94219f584 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Wed, 24 Dec 2025 10:21:41 -0800 Subject: [PATCH 09/14] test: add override_timezone and fallback scenarios to getTimeZone tests - Test valid override_timezone setting usage - Test fallback to GPS when invalid override_timezone provided - Test fallback to UTC when coordinates missing --- .../src/utils/locale.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/edge-apps/edge-apps-library/src/utils/locale.test.ts b/edge-apps/edge-apps-library/src/utils/locale.test.ts index a2984bde6..d971c3ce7 100644 --- a/edge-apps/edge-apps-library/src/utils/locale.test.ts +++ b/edge-apps/edge-apps-library/src/utils/locale.test.ts @@ -38,6 +38,43 @@ describe('locale utilities', () => { const timezone = await getTimeZone() expect(timezone).toBe('Asia/Tokyo') }) + + test('should use valid override_timezone setting', async () => { + setupScreenlyMock( + { + coordinates: [37.3861, -122.0839], + }, + { + override_timezone: 'Europe/Paris', + }, + ) + + const timezone = await getTimeZone() + expect(timezone).toBe('Europe/Paris') + }) + + test('should fallback to GPS detection for invalid override_timezone', async () => { + setupScreenlyMock( + { + coordinates: [51.5074, -0.1278], + }, + { + override_timezone: 'Invalid/Timezone', + }, + ) + + const timezone = await getTimeZone() + expect(timezone).toBe('Europe/London') + }) + + test('should fallback to UTC when coordinates are missing', async () => { + setupScreenlyMock({ + coordinates: undefined, + }) + + const timezone = await getTimeZone() + expect(timezone).toBe('UTC') + }) }) describe('formatCoordinates', () => { From e76ef9650414204a48ca161672096f006f2f9301 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Wed, 24 Dec 2025 10:31:12 -0800 Subject: [PATCH 10/14] test: add getLocale tests including multiple underscore normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/utils/locale.test.ts | 47 ++++++++++++++++++- .../edge-apps-library/src/utils/locale.ts | 2 +- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/edge-apps/edge-apps-library/src/utils/locale.test.ts b/edge-apps/edge-apps-library/src/utils/locale.test.ts index d971c3ce7..1542ce08c 100644 --- a/edge-apps/edge-apps-library/src/utils/locale.test.ts +++ b/edge-apps/edge-apps-library/src/utils/locale.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test' -import { getTimeZone, formatCoordinates } from './locale' +import { getTimeZone, formatCoordinates, getLocale } from './locale' import { setupScreenlyMock, resetScreenlyMock } from '../test/mock' describe('locale utilities', () => { @@ -100,4 +100,49 @@ describe('locale utilities', () => { expect(formatted).toBe('0.0000° S, 0.0000° W') }) }) + + describe('getLocale', () => { + test('should normalize single underscore in override_locale', async () => { + setupScreenlyMock( + { + coordinates: [37.3861, -122.0839], + }, + { + override_locale: 'en_US', + }, + ) + + const locale = await getLocale() + expect(locale).toBe('en-US') + }) + + test('should normalize multiple underscores in override_locale', async () => { + setupScreenlyMock( + { + coordinates: [37.3861, -122.0839], + }, + { + override_locale: 'en_US_POSIX', + }, + ) + + const locale = await getLocale() + expect(locale).toBe('en-US-POSIX') + }) + + test('should fallback to GPS detection for invalid override_locale', async () => { + setupScreenlyMock( + { + coordinates: [35.6762, 139.6503], // Tokyo, Japan + }, + { + override_locale: 'invalid_locale_xyz', + }, + ) + + const locale = await getLocale() + // Should fallback to GPS-based locale detection (ja for Japan) + expect(locale.startsWith('ja')).toBe(true) + }) + }) }) diff --git a/edge-apps/edge-apps-library/src/utils/locale.ts b/edge-apps/edge-apps-library/src/utils/locale.ts index e0c9307aa..4e6b4db4c 100644 --- a/edge-apps/edge-apps-library/src/utils/locale.ts +++ b/edge-apps/edge-apps-library/src/utils/locale.ts @@ -71,7 +71,7 @@ export async function getLocale(): Promise { // Priority 1: Use override setting if provided and valid const overrideLocale = getSettingWithDefault('override_locale', '') if (overrideLocale) { - const normalizedLocale = overrideLocale.replace('_', '-') + const normalizedLocale = overrideLocale.replaceAll('_', '-') // Validate the override locale if (isValidLocale(normalizedLocale)) { return normalizedLocale From 37e7cf42aacd43d4662cd986eb3284bf5802912d Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Wed, 24 Dec 2025 10:37:36 -0800 Subject: [PATCH 11/14] feat: improve locale validation with stricter format checking - 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 --- .../src/utils/locale.test.ts | 49 ++++++++++++++++++- .../edge-apps-library/src/utils/locale.ts | 31 +++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/edge-apps/edge-apps-library/src/utils/locale.test.ts b/edge-apps/edge-apps-library/src/utils/locale.test.ts index 1542ce08c..5a3151ca2 100644 --- a/edge-apps/edge-apps-library/src/utils/locale.test.ts +++ b/edge-apps/edge-apps-library/src/utils/locale.test.ts @@ -122,12 +122,12 @@ describe('locale utilities', () => { coordinates: [37.3861, -122.0839], }, { - override_locale: 'en_US_POSIX', + override_locale: 'de_DE', }, ) const locale = await getLocale() - expect(locale).toBe('en-US-POSIX') + expect(locale).toBe('de-DE') }) test('should fallback to GPS detection for invalid override_locale', async () => { @@ -144,5 +144,50 @@ describe('locale utilities', () => { // Should fallback to GPS-based locale detection (ja for Japan) expect(locale.startsWith('ja')).toBe(true) }) + + test('should reject malformed locale with trailing hyphen', async () => { + setupScreenlyMock( + { + coordinates: [35.6762, 139.6503], + }, + { + override_locale: 'en-', + }, + ) + + const locale = await getLocale() + // Should fallback to GPS-based locale detection + expect(locale.startsWith('ja')).toBe(true) + }) + + test('should reject malformed locale where region code is invalid', async () => { + setupScreenlyMock( + { + coordinates: [35.6762, 139.6503], + }, + { + override_locale: 'en-INVALID', + }, + ) + + const locale = await getLocale() + // Should fallback to GPS-based locale detection (not use 'en') + expect(locale.startsWith('ja')).toBe(true) + }) + + test('should reject locale with underscores and invalid parts', async () => { + setupScreenlyMock( + { + coordinates: [35.6762, 139.6503], + }, + { + override_locale: 'en_US_INVALID_EXTRA', + }, + ) + + const locale = await getLocale() + // Should fallback to GPS-based locale detection + expect(locale.startsWith('ja')).toBe(true) + }) }) }) diff --git a/edge-apps/edge-apps-library/src/utils/locale.ts b/edge-apps/edge-apps-library/src/utils/locale.ts index 4e6b4db4c..c79f2834a 100644 --- a/edge-apps/edge-apps-library/src/utils/locale.ts +++ b/edge-apps/edge-apps-library/src/utils/locale.ts @@ -21,6 +21,18 @@ function isValidTimezone(timezone: string): boolean { * Ensures the resolved locale's language code matches the requested locale */ function isValidLocale(locale: string): boolean { + // Basic format validation: should be at least 2 characters + // and not end with a hyphen + if (!locale || locale.length < 2 || locale.endsWith('-')) { + return false + } + + // Language code should be 2-3 characters, followed by optional region/script + const localeRegex = /^[a-z]{2,3}(-[a-z]{2,})*$/i + if (!localeRegex.test(locale)) { + return false + } + try { const formatter = new Intl.DateTimeFormat(locale) const resolved = formatter.resolvedOptions().locale @@ -28,7 +40,24 @@ function isValidLocale(locale: string): boolean { // 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 + + if (requestedLanguage !== resolvedLanguage) { + return false + } + + // If request includes a region/script, verify it's preserved in resolution + // This catches cases like 'en-INVALID' resolving to 'en' + const requestedParts = locale.toLowerCase().split('-') + if (requestedParts.length > 1) { + const resolvedParts = resolved.toLowerCase().split('-') + // If we requested more than just language, the resolved should have similar depth + // (or not drop the requested parts entirely) + if (resolvedParts.length < requestedParts.length) { + return false + } + } + + return true } catch { return false } From f82e495e0926eafe028d1acd9e425dbd87bd0634 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Wed, 24 Dec 2025 10:42:26 -0800 Subject: [PATCH 12/14] feat: use Intl.Locale API for robust numeral system handling - 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 --- .../src/utils/format-time.test.ts | 14 ++++++++++++++ .../edge-apps-library/src/utils/locale.ts | 19 +++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/edge-apps/edge-apps-library/src/utils/format-time.test.ts b/edge-apps/edge-apps-library/src/utils/format-time.test.ts index 194d56509..78b3dc18c 100644 --- a/edge-apps/edge-apps-library/src/utils/format-time.test.ts +++ b/edge-apps/edge-apps-library/src/utils/format-time.test.ts @@ -133,4 +133,18 @@ describe('formatTime', () => { console.warn = originalWarn } }) + + test('should handle locales with existing extensions', () => { + // Test with a locale that has existing extensions + // zh-CN-u-ca-chinese has calendar extension + const result = formatTime(testDate, 'zh-CN-u-ca-chinese', 'UTC') + expect(result).toHaveProperty('hour') + expect(result).toHaveProperty('minute') + expect(result).toHaveProperty('second') + expect(result).toHaveProperty('formatted') + // Should use Chinese numerals despite existing extension + expect(result.hour).toBe('一四') + expect(result.minute).toBe('三〇') + expect(result.second).toBe('四五') + }) }) diff --git a/edge-apps/edge-apps-library/src/utils/locale.ts b/edge-apps/edge-apps-library/src/utils/locale.ts index c79f2834a..59532ab98 100644 --- a/edge-apps/edge-apps-library/src/utils/locale.ts +++ b/edge-apps/edge-apps-library/src/utils/locale.ts @@ -288,6 +288,7 @@ function extractTimePartsFromFormatter( /** * Get locale extension for numeral system based on language * This enables locale-specific number representations (e.g., Thai numerals, Chinese numerals) + * Uses Intl.Locale API to robustly handle existing extensions */ function getLocaleWithNumeralSystem(locale: string): string { const language = locale.toLowerCase().split('-')[0] @@ -299,12 +300,22 @@ function getLocaleWithNumeralSystem(locale: string): string { } const numeralSystem = numeralSystemMap[language] - if (numeralSystem) { - // Add numeral system extension using Unicode extension - return `${locale}-u-nu-${numeralSystem}` + if (!numeralSystem) { + return locale } - return locale + try { + // Use Intl.Locale API to robustly handle existing extensions + // This properly merges extensions instead of creating duplicates + const localeObj = new Intl.Locale(locale, { + numberingSystem: numeralSystem, + }) + return localeObj.toString() + } catch (error) { + // Fallback to original locale if Intl.Locale fails + console.warn(`Failed to apply numeral system to locale "${locale}":`, error) + return locale + } } /** From 67526664061beb9e139d3b760f8a7a8b0e65eeff Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Wed, 24 Dec 2025 11:04:52 -0800 Subject: [PATCH 13/14] refactor: parametrize locale-specific tests for concurrent execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/utils/get-localized-day-names.test.ts | 348 +++++----- .../utils/get-localized-month-names.test.ts | 624 +++++++++--------- 2 files changed, 488 insertions(+), 484 deletions(-) diff --git a/edge-apps/edge-apps-library/src/utils/get-localized-day-names.test.ts b/edge-apps/edge-apps-library/src/utils/get-localized-day-names.test.ts index 4ddf1f605..0dfdb301e 100644 --- a/edge-apps/edge-apps-library/src/utils/get-localized-day-names.test.ts +++ b/edge-apps/edge-apps-library/src/utils/get-localized-day-names.test.ts @@ -28,185 +28,187 @@ describe('getLocalizedDayNames', () => { ]) }) - test('should return localized longhand day names for different locales', () => { - const localeExpectations: Array<{ - locale: string - full: string[] - }> = [ - { - locale: 'en-GB', - full: [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ], - }, - { - locale: 'de-DE', - full: [ - 'Sonntag', - 'Montag', - 'Dienstag', - 'Mittwoch', - 'Donnerstag', - 'Freitag', - 'Samstag', - ], - }, - { - locale: 'ja-JP', - full: [ - '日曜日', - '月曜日', - '火曜日', - '水曜日', - '木曜日', - '金曜日', - '土曜日', - ], - }, - { - locale: 'fr-FR', - full: [ - 'dimanche', - 'lundi', - 'mardi', - 'mercredi', - 'jeudi', - 'vendredi', - 'samedi', - ], - }, - { - locale: 'es-ES', - full: [ - 'domingo', - 'lunes', - 'martes', - 'miércoles', - 'jueves', - 'viernes', - 'sábado', - ], - }, - { - locale: 'ru-RU', - full: [ - 'воскресенье', - 'понедельник', - 'вторник', - 'среда', - 'четверг', - 'пятница', - 'суббота', - ], - }, - ] + // Parametrized tests for full day names across locales + const localeExpectations: Array<{ + locale: string + full: string[] + }> = [ + { + locale: 'en-GB', + full: [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ], + }, + { + locale: 'de-DE', + full: [ + 'Sonntag', + 'Montag', + 'Dienstag', + 'Mittwoch', + 'Donnerstag', + 'Freitag', + 'Samstag', + ], + }, + { + locale: 'ja-JP', + full: [ + '日曜日', + '月曜日', + '火曜日', + '水曜日', + '木曜日', + '金曜日', + '土曜日', + ], + }, + { + locale: 'fr-FR', + full: [ + 'dimanche', + 'lundi', + 'mardi', + 'mercredi', + 'jeudi', + 'vendredi', + 'samedi', + ], + }, + { + locale: 'es-ES', + full: [ + 'domingo', + 'lunes', + 'martes', + 'miércoles', + 'jueves', + 'viernes', + 'sábado', + ], + }, + { + locale: 'ru-RU', + full: [ + 'воскресенье', + 'понедельник', + 'вторник', + 'среда', + 'четверг', + 'пятница', + 'суббота', + ], + }, + ] - for (const { locale, full: expectedFull } of localeExpectations) { + for (const { locale, full: expectedFull } of localeExpectations) { + test(`should return full day names for ${locale}`, () => { const result = getLocalizedDayNames(locale) expect(result.full).toEqual(expectedFull) - } - }) + }) + } - test('should return full and shorthand day names for different locales', () => { - const localeExpectations: Array<{ - locale: string - full: string[] - short: string[] - }> = [ - { - locale: 'en', - full: [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ], - short: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], - }, - { - locale: 'de', - full: [ - 'Sonntag', - 'Montag', - 'Dienstag', - 'Mittwoch', - 'Donnerstag', - 'Freitag', - 'Samstag', - ], - short: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'], - }, - { - locale: 'ja', - full: [ - '日曜日', - '月曜日', - '火曜日', - '水曜日', - '木曜日', - '金曜日', - '土曜日', - ], - short: ['日', '月', '火', '水', '木', '金', '土'], - }, - { - locale: 'fr', - full: [ - 'dimanche', - 'lundi', - 'mardi', - 'mercredi', - 'jeudi', - 'vendredi', - 'samedi', - ], - short: ['dim.', 'lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.'], - }, - { - locale: 'es', - full: [ - 'domingo', - 'lunes', - 'martes', - 'miércoles', - 'jueves', - 'viernes', - 'sábado', - ], - short: ['dom', 'lun', 'mar', 'mié', 'jue', 'vie', 'sáb'], - }, - { - locale: 'ru', - full: [ - 'воскресенье', - 'понедельник', - 'вторник', - 'среда', - 'четверг', - 'пятница', - 'суббота', - ], - short: ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'], - }, - ] + // Parametrized tests for full and short day names across locales + const localeShortExpectations: Array<{ + locale: string + full: string[] + short: string[] + }> = [ + { + locale: 'en', + full: [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ], + short: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + }, + { + locale: 'de', + full: [ + 'Sonntag', + 'Montag', + 'Dienstag', + 'Mittwoch', + 'Donnerstag', + 'Freitag', + 'Samstag', + ], + short: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'], + }, + { + locale: 'ja', + full: [ + '日曜日', + '月曜日', + '火曜日', + '水曜日', + '木曜日', + '金曜日', + '土曜日', + ], + short: ['日', '月', '火', '水', '木', '金', '土'], + }, + { + locale: 'fr', + full: [ + 'dimanche', + 'lundi', + 'mardi', + 'mercredi', + 'jeudi', + 'vendredi', + 'samedi', + ], + short: ['dim.', 'lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.'], + }, + { + locale: 'es', + full: [ + 'domingo', + 'lunes', + 'martes', + 'miércoles', + 'jueves', + 'viernes', + 'sábado', + ], + short: ['dom', 'lun', 'mar', 'mié', 'jue', 'vie', 'sáb'], + }, + { + locale: 'ru', + full: [ + 'воскресенье', + 'понедельник', + 'вторник', + 'среда', + 'четверг', + 'пятница', + 'суббота', + ], + short: ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'], + }, + ] - for (const { - locale, - full: expectedFull, - short: expectedShort, - } of localeExpectations) { + for (const { + locale, + full: expectedFull, + short: expectedShort, + } of localeShortExpectations) { + test(`should return full and short day names for ${locale}`, () => { const result = getLocalizedDayNames(locale) expect(result.full).toEqual(expectedFull) expect(result.short).toEqual(expectedShort) - } - }) + }) + } }) diff --git a/edge-apps/edge-apps-library/src/utils/get-localized-month-names.test.ts b/edge-apps/edge-apps-library/src/utils/get-localized-month-names.test.ts index a66d92d4e..ba73e11af 100644 --- a/edge-apps/edge-apps-library/src/utils/get-localized-month-names.test.ts +++ b/edge-apps/edge-apps-library/src/utils/get-localized-month-names.test.ts @@ -38,323 +38,325 @@ describe('getLocalizedMonthNames', () => { ]) }) - test('should return localized longhand month names for different locales', () => { - const localeExpectations: Array<{ - locale: string - full: string[] - }> = [ - { - locale: 'en-GB', - full: [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ], - }, - { - locale: 'de-DE', - full: [ - 'Januar', - 'Februar', - 'März', - 'April', - 'Mai', - 'Juni', - 'Juli', - 'August', - 'September', - 'Oktober', - 'November', - 'Dezember', - ], - }, - { - locale: 'ja-JP', - full: [ - '1月', - '2月', - '3月', - '4月', - '5月', - '6月', - '7月', - '8月', - '9月', - '10月', - '11月', - '12月', - ], - }, - { - locale: 'fr-FR', - full: [ - 'janvier', - 'février', - 'mars', - 'avril', - 'mai', - 'juin', - 'juillet', - 'août', - 'septembre', - 'octobre', - 'novembre', - 'décembre', - ], - }, - { - locale: 'es-ES', - full: [ - 'enero', - 'febrero', - 'marzo', - 'abril', - 'mayo', - 'junio', - 'julio', - 'agosto', - 'septiembre', - 'octubre', - 'noviembre', - 'diciembre', - ], - }, - { - locale: 'ru-RU', - full: [ - 'январь', - 'февраль', - 'март', - 'апрель', - 'май', - 'июнь', - 'июль', - 'август', - 'сентябрь', - 'октябрь', - 'ноябрь', - 'декабрь', - ], - }, - ] + // Parametrized tests for full month names across locales + const localeExpectations: Array<{ + locale: string + full: string[] + }> = [ + { + locale: 'en-GB', + full: [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ], + }, + { + locale: 'de-DE', + full: [ + 'Januar', + 'Februar', + 'März', + 'April', + 'Mai', + 'Juni', + 'Juli', + 'August', + 'September', + 'Oktober', + 'November', + 'Dezember', + ], + }, + { + locale: 'ja-JP', + full: [ + '1月', + '2月', + '3月', + '4月', + '5月', + '6月', + '7月', + '8月', + '9月', + '10月', + '11月', + '12月', + ], + }, + { + locale: 'fr-FR', + full: [ + 'janvier', + 'février', + 'mars', + 'avril', + 'mai', + 'juin', + 'juillet', + 'août', + 'septembre', + 'octobre', + 'novembre', + 'décembre', + ], + }, + { + locale: 'es-ES', + full: [ + 'enero', + 'febrero', + 'marzo', + 'abril', + 'mayo', + 'junio', + 'julio', + 'agosto', + 'septiembre', + 'octubre', + 'noviembre', + 'diciembre', + ], + }, + { + locale: 'ru-RU', + full: [ + 'январь', + 'февраль', + 'март', + 'апрель', + 'май', + 'июнь', + 'июль', + 'август', + 'сентябрь', + 'октябрь', + 'ноябрь', + 'декабрь', + ], + }, + ] - for (const { locale, full: expectedFull } of localeExpectations) { + for (const { locale, full: expectedFull } of localeExpectations) { + test(`should return full month names for ${locale}`, () => { const result = getLocalizedMonthNames(locale) expect(result.full).toEqual(expectedFull) - } - }) + }) + } - test('should return full and short month names for different locales', () => { - const localeExpectations: Array<{ - locale: string - full: string[] - short: string[] - }> = [ - { - locale: 'en', - full: [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ], - short: [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ], - }, - { - locale: 'de', - full: [ - 'Januar', - 'Februar', - 'März', - 'April', - 'Mai', - 'Juni', - 'Juli', - 'August', - 'September', - 'Oktober', - 'November', - 'Dezember', - ], - short: [ - 'Jan', - 'Feb', - 'Mär', - 'Apr', - 'Mai', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Okt', - 'Nov', - 'Dez', - ], - }, - { - locale: 'ja', - full: [ - '1月', - '2月', - '3月', - '4月', - '5月', - '6月', - '7月', - '8月', - '9月', - '10月', - '11月', - '12月', - ], - short: [ - '1月', - '2月', - '3月', - '4月', - '5月', - '6月', - '7月', - '8月', - '9月', - '10月', - '11月', - '12月', - ], - }, - { - locale: 'fr', - full: [ - 'janvier', - 'février', - 'mars', - 'avril', - 'mai', - 'juin', - 'juillet', - 'août', - 'septembre', - 'octobre', - 'novembre', - 'décembre', - ], - short: [ - 'janv.', - 'févr.', - 'mars', - 'avr.', - 'mai', - 'juin', - 'juil.', - 'août', - 'sept.', - 'oct.', - 'nov.', - 'déc.', - ], - }, - { - locale: 'es', - full: [ - 'enero', - 'febrero', - 'marzo', - 'abril', - 'mayo', - 'junio', - 'julio', - 'agosto', - 'septiembre', - 'octubre', - 'noviembre', - 'diciembre', - ], - short: [ - 'ene', - 'feb', - 'mar', - 'abr', - 'may', - 'jun', - 'jul', - 'ago', - 'sept', - 'oct', - 'nov', - 'dic', - ], - }, - { - locale: 'ru', - full: [ - 'январь', - 'февраль', - 'март', - 'апрель', - 'май', - 'июнь', - 'июль', - 'август', - 'сентябрь', - 'октябрь', - 'ноябрь', - 'декабрь', - ], - short: [ - 'янв.', - 'февр.', - 'март', - 'апр.', - 'май', - 'июнь', - 'июль', - 'авг.', - 'сент.', - 'окт.', - 'нояб.', - 'дек.', - ], - }, - ] + // Parametrized tests for full and short month names across locales + const localeShortExpectations: Array<{ + locale: string + full: string[] + short: string[] + }> = [ + { + locale: 'en', + full: [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ], + short: [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ], + }, + { + locale: 'de', + full: [ + 'Januar', + 'Februar', + 'März', + 'April', + 'Mai', + 'Juni', + 'Juli', + 'August', + 'September', + 'Oktober', + 'November', + 'Dezember', + ], + short: [ + 'Jan', + 'Feb', + 'Mär', + 'Apr', + 'Mai', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Okt', + 'Nov', + 'Dez', + ], + }, + { + locale: 'ja', + full: [ + '1月', + '2月', + '3月', + '4月', + '5月', + '6月', + '7月', + '8月', + '9月', + '10月', + '11月', + '12月', + ], + short: [ + '1月', + '2月', + '3月', + '4月', + '5月', + '6月', + '7月', + '8月', + '9月', + '10月', + '11月', + '12月', + ], + }, + { + locale: 'fr', + full: [ + 'janvier', + 'février', + 'mars', + 'avril', + 'mai', + 'juin', + 'juillet', + 'août', + 'septembre', + 'octobre', + 'novembre', + 'décembre', + ], + short: [ + 'janv.', + 'févr.', + 'mars', + 'avr.', + 'mai', + 'juin', + 'juil.', + 'août', + 'sept.', + 'oct.', + 'nov.', + 'déc.', + ], + }, + { + locale: 'es', + full: [ + 'enero', + 'febrero', + 'marzo', + 'abril', + 'mayo', + 'junio', + 'julio', + 'agosto', + 'septiembre', + 'octubre', + 'noviembre', + 'diciembre', + ], + short: [ + 'ene', + 'feb', + 'mar', + 'abr', + 'may', + 'jun', + 'jul', + 'ago', + 'sept', + 'oct', + 'nov', + 'dic', + ], + }, + { + locale: 'ru', + full: [ + 'январь', + 'февраль', + 'март', + 'апрель', + 'май', + 'июнь', + 'июль', + 'август', + 'сентябрь', + 'октябрь', + 'ноябрь', + 'декабрь', + ], + short: [ + 'янв.', + 'февр.', + 'март', + 'апр.', + 'май', + 'июнь', + 'июль', + 'авг.', + 'сент.', + 'окт.', + 'нояб.', + 'дек.', + ], + }, + ] - for (const { - locale, - full: expectedFull, - short: expectedShort, - } of localeExpectations) { + for (const { + locale, + full: expectedFull, + short: expectedShort, + } of localeShortExpectations) { + test(`should return full and short month names for ${locale}`, () => { const result = getLocalizedMonthNames(locale) expect(result.full).toEqual(expectedFull) expect(result.short).toEqual(expectedShort) - } - }) + }) + } }) From 68b12230c007894593ae5837eb03f865f6d27aa3 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Wed, 24 Dec 2025 11:21:59 -0800 Subject: [PATCH 14/14] build: enable parallel test execution via --parallel flag - Add --parallel flag to test scripts in package.json - All test suites now run concurrently - Speeds up overall test execution time --- edge-apps/edge-apps-library/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/edge-apps/edge-apps-library/package.json b/edge-apps/edge-apps-library/package.json index 7c6a7be9a..45a1f3a9b 100644 --- a/edge-apps/edge-apps-library/package.json +++ b/edge-apps/edge-apps-library/package.json @@ -30,9 +30,9 @@ "scripts": { "build": "bun build:types", "build:types": "tsc", - "test": "bun test", - "test:unit": "bun test", - "test:coverage": "bun test --coverage", + "test": "bun test --parallel", + "test:unit": "bun test --parallel", + "test:coverage": "bun test --coverage --parallel", "lint": "tsc --noEmit", "format": "prettier --write src/ README.md index.html" },