Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions app/components/Package/TableRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
12 changes: 10 additions & 2 deletions app/components/Package/WeeklyDownloadStats.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -197,8 +198,8 @@ const dataset = computed<VueUiSparklineDatasetItem[]>(() =>
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') : '-',
}),
})),
)
Expand Down Expand Up @@ -244,6 +245,9 @@ const config = computed<VueUiSparklineConfig>(() => {
fontSize: 28,
bold: false,
color: colors.value.fg,
formatter: ({ value }) => {
return numberFormatter.value.format(value)
},
},
line: {
color: colors.value.borderHover,
Expand Down Expand Up @@ -394,6 +398,10 @@ const config = computed<VueUiSparklineConfig>(() => {

<style>
/** Overrides */
/* TODO: remove this style once we have rtl support */
.vue-ui-sparkline svg {
direction: ltr;
}
.vue-ui-sparkline-title span {
padding: 0 !important;
letter-spacing: 0.04rem;
Expand Down
63 changes: 45 additions & 18 deletions app/composables/useNumberFormatter.ts
Original file line number Diff line number Diff line change
@@ -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 = () =>
Expand All @@ -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<string, Intl.NumberFormat>()

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)
},
}
}
3 changes: 3 additions & 0 deletions app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions app/composables/useUserLocale.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
12 changes: 12 additions & 0 deletions app/pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
const { currentLocaleStatus, isSourceLocale } = useI18nStatus()
const keyboardShortcutsEnabled = useKeyboardShortcuts()

const rtl = new Map<string, boolean>(locales.value.map(l => [l.code, l.dir === 'rtl']))

Check failure on line 10 in app/pages/settings.vue

View workflow job for this annotation

GitHub Actions / 💪 Type check

'rtl' is declared but its value is never read.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

don't remove this, maybe we'll need it


// Escape to go back (but not when focused on form elements or modal is open)
onKeyStroke(
e =>
Expand Down Expand Up @@ -142,6 +144,16 @@
:description="$t('settings.hide_platform_packages_description')"
v-model="settings.hidePlatformPackages"
/>

<!-- Divider -->
<div class="border-t border-border my-4" />

<!-- System locale toggle -->
<SettingsToggle
:label="$t('settings.use_system_locale')"
:description="$t('settings.use_system_locale_description')"
v-model="settings.useSystemLocaleForFormatting"
/>
</div>
</section>

Expand Down
2 changes: 2 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,12 @@
"language": {
"type": "string"
},
"use_system_locale": {
"type": "string"
},
"use_system_locale_description": {
"type": "string"
},
"help_translate": {
"type": "string"
},
Expand Down
2 changes: 2 additions & 0 deletions lunaria/files/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions lunaria/files/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading