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 diff --git a/edge-apps/edge-apps-library/package.json b/edge-apps/edge-apps-library/package.json index e7f877684..8642cf778 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", "format:check": "prettier --check src/ README.md index.html" 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..78b3dc18c --- /dev/null +++ b/edge-apps/edge-apps-library/src/utils/format-time.test.ts @@ -0,0 +1,150 @@ +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 + } + }) + + 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/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..0dfdb301e --- /dev/null +++ b/edge-apps/edge-apps-library/src/utils/get-localized-day-names.test.ts @@ -0,0 +1,214 @@ +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', + ]) + }) + + // 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) { + test(`should return full day names for ${locale}`, () => { + const result = getLocalizedDayNames(locale) + expect(result.full).toEqual(expectedFull) + }) + } + + // 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 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 new file mode 100644 index 000000000..ba73e11af --- /dev/null +++ b/edge-apps/edge-apps-library/src/utils/get-localized-month-names.test.ts @@ -0,0 +1,362 @@ +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', + ]) + }) + + // 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) { + test(`should return full month names for ${locale}`, () => { + const result = getLocalizedMonthNames(locale) + expect(result.full).toEqual(expectedFull) + }) + } + + // 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 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) + }) + } +}) 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..5a3151ca2 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', () => { @@ -12,32 +12,69 @@ 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') }) + + 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', () => { @@ -63,4 +100,94 @@ 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: 'de_DE', + }, + ) + + const locale = await getLocale() + expect(locale).toBe('de-DE') + }) + + 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) + }) + + 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 2deadad1c..59532ab98 100644 --- a/edge-apps/edge-apps-library/src/utils/locale.ts +++ b/edge-apps/edge-apps-library/src/utils/locale.ts @@ -1,23 +1,116 @@ 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 + } +} + +/** + * Validate locale using native Intl API + * 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 + // 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] + + 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 + } +} + +/** + * 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' + } } /** - * 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 locale configuration with fallback chain + * Fallback order: override setting (validated) → GPS-based detection → browser locale → 'en' */ export async function getLocale(): Promise { - const [lat, lng] = screenly.metadata.coordinates + // Priority 1: Use override setting if provided and valid + const overrideLocale = getSettingWithDefault('override_locale', '') + if (overrideLocale) { + const normalizedLocale = overrideLocale.replaceAll('_', '-') + // 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 const defaultLocale = (navigator?.languages?.length @@ -50,3 +143,240 @@ 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[] = [] + + // 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 } +} + +/** + * 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, + } +} + +/** + * 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] + + // 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) { + 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 + } +} + +/** + * 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' + + // Get locale with numeral system extension if applicable + const localeWithNumerals = getLocaleWithNumeralSystem(locale) + + // Format with Intl API for proper localization + const formatter = new Intl.DateTimeFormat(localeWithNumerals, { + 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), + } + } +}