From d7f37ac37f1830b99ccb7dd71ee41d228ad2a024 Mon Sep 17 00:00:00 2001 From: userquin Date: Fri, 27 Feb 2026 23:41:45 +0100 Subject: [PATCH 1/3] feat(i18n): add browser locale support for formatting --- app/components/Package/TableRow.vue | 7 ++- .../Package/WeeklyDownloadStats.vue | 4 ++ app/composables/useNumberFormatter.ts | 63 +++++++++++++------ app/composables/useSettings.ts | 3 + app/composables/useUserLocale.ts | 26 ++++++++ app/pages/settings.vue | 12 ++++ i18n/locales/en.json | 2 + i18n/schema.json | 6 ++ lunaria/files/en-GB.json | 2 + lunaria/files/en-US.json | 2 + 10 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 app/composables/useUserLocale.ts diff --git a/app/components/Package/TableRow.vue b/app/components/Package/TableRow.vue index 267b7f1b9..578608d79 100644 --- a/app/components/Package/TableRow.vue +++ b/app/components/Package/TableRow.vue @@ -18,10 +18,13 @@ const score = computed(() => props.result.score) const updatedDate = computed(() => props.result.package.date) +const compactNumberFormatter = useCompactNumberFormatter() + function formatDownloads(count?: number): string { if (count === undefined) return '-' - if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M` - if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K` + if (count >= 1_000_000 || count >= 1_000) return compactNumberFormatter.value.format(count) + // if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M` + // if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K` return count.toString() } diff --git a/app/components/Package/WeeklyDownloadStats.vue b/app/components/Package/WeeklyDownloadStats.vue index 6f1fa4def..a10fbffa3 100644 --- a/app/components/Package/WeeklyDownloadStats.vue +++ b/app/components/Package/WeeklyDownloadStats.vue @@ -20,6 +20,7 @@ const { settings } = useSettings() const chartModal = useModal('chart-modal') const hasChartModalTransitioned = shallowRef(false) +const numberFormatter = useNumberFormatter() const modalTitle = computed(() => { const facet = route.query.facet as string | undefined @@ -244,6 +245,9 @@ const config = computed(() => { fontSize: 28, bold: false, color: colors.value.fg, + formatter: ({ value }) => { + return numberFormatter.value.format(value) + }, }, line: { color: colors.value.borderHover, diff --git a/app/composables/useNumberFormatter.ts b/app/composables/useNumberFormatter.ts index a375bc043..4c5aa1f8c 100644 --- a/app/composables/useNumberFormatter.ts +++ b/app/composables/useNumberFormatter.ts @@ -1,7 +1,15 @@ export function useNumberFormatter(options?: Intl.NumberFormatOptions) { - const { locale } = useI18n() + const { userLocale } = useUserLocale() - return computed(() => new Intl.NumberFormat(locale.value, options)) + return computed( + () => + new Intl.NumberFormat( + userLocale.value, + options ?? { + maximumFractionDigits: 0, + }, + ), + ) } export const useCompactNumberFormatter = () => @@ -12,26 +20,45 @@ export const useCompactNumberFormatter = () => }) export const useBytesFormatter = () => { - const { t } = useI18n() - const decimalNumberFormatter = useNumberFormatter({ - maximumFractionDigits: 1, + const { userLocale } = useUserLocale() + + const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte', 'petabyte'] + + // Create formatters reactively based on the user's preferred locale. + // This ensures that when the locale (or the setting) changes, all formatters are recreated. + const formatters = computed(() => { + const locale = userLocale.value + const map = new Map() + + units.forEach(unit => { + map.set( + unit, + new Intl.NumberFormat(locale, { + style: 'unit', + unit, + unitDisplay: 'short', + maximumFractionDigits: 2, + }), + ) + }) + + return map }) - const KB = 1000 - const MB = 1000 * 1000 return { format: (bytes: number) => { - if (bytes < KB) - return t('package.size.b', { - size: decimalNumberFormatter.value.format(bytes), - }) - if (bytes < MB) - return t('package.size.kb', { - size: decimalNumberFormatter.value.format(bytes / KB), - }) - return t('package.size.mb', { - size: decimalNumberFormatter.value.format(bytes / MB), - }) + let value = bytes + let unitIndex = 0 + + // Use 1_000 as base (SI units) instead of 1_024. + while (value >= 1_000 && unitIndex < units.length - 1) { + value /= 1_000 + unitIndex++ + } + + const unit = units[unitIndex]! + // Accessing formatters.value here establishes the reactive dependency + return formatters.value.get(unit)!.format(value) }, } } diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts index 5e45b4218..d1c3e1bd3 100644 --- a/app/composables/useSettings.ts +++ b/app/composables/useSettings.ts @@ -27,6 +27,8 @@ export interface AppSettings { hidePlatformPackages: boolean /** User-selected locale */ selectedLocale: LocaleObject['code'] | null + /** Use the browser's locale for number and date formatting instead of the app's locale */ + useSystemLocaleForFormatting: boolean /** Search provider for package search */ searchProvider: SearchProvider /** Enable/disable keyboard shortcuts */ @@ -53,6 +55,7 @@ const DEFAULT_SETTINGS: AppSettings = { accentColorId: null, hidePlatformPackages: true, selectedLocale: null, + useSystemLocaleForFormatting: false, preferredBackgroundTheme: null, searchProvider: import.meta.test ? 'npm' : 'algolia', keyboardShortcuts: true, diff --git a/app/composables/useUserLocale.ts b/app/composables/useUserLocale.ts new file mode 100644 index 000000000..1cc3e90a2 --- /dev/null +++ b/app/composables/useUserLocale.ts @@ -0,0 +1,26 @@ +import { usePreferredLanguages } from '@vueuse/core' + +/** + * Composable to determine the best locale for formatting numbers and dates. + * It respects the user's preference to use the system/browser locale or the app's selected locale. + */ +export const useUserLocale = () => { + const { locale } = useI18n() + const { settings } = useSettings() + const languages = usePreferredLanguages() + + const userLocale = computed(() => { + // If the user wants to use the system locale and we are on the client side with available languages + if (settings.value.useSystemLocaleForFormatting && languages.value.length > 0) { + return languages.value[0] + } + + // Fallback to the app's selected locale (also used during SSR to avoid hydration mismatch if possible, + // though formatting might change on client hydration if system locale differs) + return locale.value + }) + + return { + userLocale, + } +} diff --git a/app/pages/settings.vue b/app/pages/settings.vue index ee158c3e4..60e8bc7ee 100644 --- a/app/pages/settings.vue +++ b/app/pages/settings.vue @@ -7,6 +7,8 @@ const colorMode = useColorMode() const { currentLocaleStatus, isSourceLocale } = useI18nStatus() const keyboardShortcutsEnabled = useKeyboardShortcuts() +const rtl = new Map(locales.value.map(l => [l.code, l.dir === 'rtl'])) + // Escape to go back (but not when focused on form elements or modal is open) onKeyStroke( e => @@ -142,6 +144,16 @@ const setLocale: typeof setNuxti18nLocale = locale => { :description="$t('settings.hide_platform_packages_description')" v-model="settings.hidePlatformPackages" /> + + +
+ + +
diff --git a/i18n/locales/en.json b/i18n/locales/en.json index c34838ccf..f0853179c 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -106,6 +106,8 @@ "theme_dark": "Dark", "theme_system": "System", "language": "Language", + "use_system_locale": "Use system locale for formatting", + "use_system_locale_description": "Use your browser's locale for number and date formatting instead of the app's language", "help_translate": "Help translate npmx", "accent_colors": "Accent colors", "clear_accent": "Clear accent color", diff --git a/i18n/schema.json b/i18n/schema.json index 5871b157f..cb9f84f4e 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -322,6 +322,12 @@ "language": { "type": "string" }, + "use_system_locale": { + "type": "string" + }, + "use_system_locale_description": { + "type": "string" + }, "help_translate": { "type": "string" }, diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index 2ca555b8c..a36780802 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -105,6 +105,8 @@ "theme_dark": "Dark", "theme_system": "System", "language": "Language", + "use_system_locale": "Use system locale for formatting", + "use_system_locale_description": "Use your browser's locale for number and date formatting instead of the app's language", "help_translate": "Help translate npmx", "accent_colors": "Accent colors", "clear_accent": "Clear accent colour", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 2fc0c472c..304b0ba5a 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -105,6 +105,8 @@ "theme_dark": "Dark", "theme_system": "System", "language": "Language", + "use_system_locale": "Use system locale for formatting", + "use_system_locale_description": "Use your browser's locale for number and date formatting instead of the app's language", "help_translate": "Help translate npmx", "accent_colors": "Accent colors", "clear_accent": "Clear accent color", From 0df4cf5dcfc68be806c8da6e0738b992c0ac1d42 Mon Sep 17 00:00:00 2001 From: userquin Date: Sat, 28 Feb 2026 00:18:20 +0100 Subject: [PATCH 2/3] chore: use `$d` for `package.trends.date_range` message --- app/components/Package/WeeklyDownloadStats.vue | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/components/Package/WeeklyDownloadStats.vue b/app/components/Package/WeeklyDownloadStats.vue index a10fbffa3..4eb68d195 100644 --- a/app/components/Package/WeeklyDownloadStats.vue +++ b/app/components/Package/WeeklyDownloadStats.vue @@ -198,8 +198,8 @@ const dataset = computed(() => correctedDownloads.value.map(d => ({ value: d?.value ?? 0, period: $t('package.trends.date_range', { - start: d.weekStart ?? '-', - end: d.weekEnd ?? '-', + start: d.weekStart ? $d(d.weekStart, 'shortDate') : '-', + end: d.weekEnd ? $d(d.weekEnd, 'shortDate') : '-', }), })), ) @@ -398,6 +398,10 @@ const config = computed(() => {