feat(ui): format dates using the selected language (#6160)

Dates were rendered with the browser locale, ignoring the language chosen in
Personal settings, so a user browsing in German still saw US-style dates. All
date rendering now resolves its locale through a useDateLocale hook that returns
the selected language, augmented with the region from navigator.languages when
the language carries none (Intl reads a bare "en" as en-US). Applied to
DateField, the rated/loved tooltips, the mobile user list and album release
dates; the three inline timestamp tooltips now share a formatDateTime helper.

Closes #229
This commit is contained in:
Deluan Quintão 2026-09-19 14:07:42 -04:00 • committed by GitHub
commit be8ec15168
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 115 additions and 7 deletions

View file

@ -31,6 +31,7 @@ import {
SizeField,
useAlbumsPerPage,
} from '../common'
import { useDateLocale } from '../i18n/useDateLocale'
import { formatFullDate, intersperse } from '../utils'
import AlbumExternalLinks from './AlbumExternalLinks'
import { SafeHTML } from '../common/SafeHTML'
@ -151,6 +152,7 @@ export const Details = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const translate = useTranslate()
const record = useRecordContext(props)
const locale = useDateLocale()
// Create an array of detail elements
let details = []
@ -161,12 +163,13 @@ export const Details = (props) => {
// Calculate date related fields
const yearRange = formatRange(record, 'year')
const date = record.date ? formatFullDate(record.date) : yearRange
const date = record.date ? formatFullDate(record.date, locale) : yearRange
const originalDate = record.originalDate
? formatFullDate(record.originalDate)
? formatFullDate(record.originalDate, locale)
: formatRange(record, 'originalYear')
const releaseDate = record?.releaseDate && formatFullDate(record.releaseDate)
const releaseDate =
record?.releaseDate && formatFullDate(record.releaseDate, locale)
const dateToUse = originalDate || date
const isOriginalDate = originalDate && dateToUse !== date

View file

@ -1,12 +1,14 @@
import React from 'react'
import { isDateSet } from '../utils/validations'
import { DateField as RADateField } from 'react-admin'
import { useDateLocale } from '../i18n/useDateLocale'
export const DateField = (props) => {
const { record, source } = props
const locale = useDateLocale()
const value = record?.[source]
if (!isDateSet(value)) return null
return <RADateField {...props} />
return <RADateField locales={locale} {...props} />
}
DateField.defaultProps = {

View file

@ -0,0 +1,32 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { DateField } from './DateField'
vi.mock('react-admin', async (importOriginal) => ({
...(await importOriginal()),
useLocale: vi.fn(),
}))
describe('<DateField>', () => {
const record = { id: '1', updatedAt: '2026-09-17T14:30:00Z' }
beforeEach(async () => {
vi.clearAllMocks()
vi.spyOn(navigator, 'languages', 'get').mockReturnValue([])
const { useLocale } = await import('react-admin')
vi.mocked(useLocale).mockReturnValue('de')
})
it('formats the date using the selected language', () => {
render(<DateField record={record} source="updatedAt" />)
expect(screen.getByText('17.9.2026')).toBeInTheDocument()
})
it('renders nothing when the date is not set', () => {
const { container } = render(
<DateField record={{ id: '1' }} source="updatedAt" />,
)
expect(container).toBeEmptyDOMElement()
})
})

View file

@ -6,6 +6,8 @@ import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles'
import clsx from 'clsx'
import { useToggleLove } from './useToggleLove'
import { useDateLocale } from '../i18n/useDateLocale'
import { formatDateTime } from '../utils/formatters'
import { useRecordContext } from 'react-admin'
import config from '../config'
import { isDateSet } from '../utils/validations'
@ -40,6 +42,7 @@ export const LoveButton = ({
const record = useRecordContext({ record: recordProp }) || {}
const classes = useStyles({ color, visible, loved: record.starred })
const [toggleLove, loading] = useToggleLove(resource, record)
const locale = useDateLocale()
const handleToggleLove = useCallback(
(e) => {
@ -61,7 +64,7 @@ export const LoveButton = ({
className={clsx(classes.love, className)}
title={
isDateSet(record.starredAt)
? new Date(record.starredAt).toLocaleString()
? formatDateTime(record.starredAt, locale)
: undefined
}
{...rest}

View file

@ -6,6 +6,8 @@ import { isDateSet } from '../utils/validations'
import StarBorderIcon from '@material-ui/icons/StarBorder'
import clsx from 'clsx'
import { useRating } from './useRating'
import { useDateLocale } from '../i18n/useDateLocale'
import { formatDateTime } from '../utils/formatters'
import { useRecordContext } from 'react-admin'
const useStyles = makeStyles({
@ -32,6 +34,7 @@ export const RatingField = ({
const record = useRecordContext(rest) || {}
const [rate, rating] = useRating(resource, record)
const classes = useStyles({ color, visible })
const locale = useDateLocale()
const stopPropagation = (e) => {
e.stopPropagation()
@ -50,7 +53,7 @@ export const RatingField = ({
onClick={(e) => stopPropagation(e)}
title={
isDateSet(record.ratedAt)
? new Date(record.ratedAt).toLocaleString()
? formatDateTime(record.ratedAt, locale)
: undefined
}
>

View file

@ -0,0 +1,14 @@
import { useLocale } from 'react-admin'
// Our language codes are mostly region-less ("en"), and Intl reads a bare "en"
// as en-US. Borrow the region from the browser when it speaks the same language.
const resolveDateLocale = (locale, browserLocales = []) => {
if (!locale || locale.includes('-')) return locale
const base = locale.toLowerCase()
return (
browserLocales.find((l) => l.toLowerCase().split('-')[0] === base) || locale
)
}
export const useDateLocale = () =>
resolveDateLocale(useLocale(), navigator.languages)

View file

@ -0,0 +1,45 @@
import { renderHook } from '@testing-library/react-hooks'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useDateLocale } from './useDateLocale'
vi.mock('react-admin', () => ({
useLocale: vi.fn(),
}))
describe('useDateLocale', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const renderWith = async (locale, browserLocales) => {
const { useLocale } = await import('react-admin')
vi.mocked(useLocale).mockReturnValue(locale)
vi.spyOn(navigator, 'languages', 'get').mockReturnValue(browserLocales)
return renderHook(() => useDateLocale()).result
}
it('adds the region from the browser when the language has none', async () => {
const result = await renderWith('en', ['en-GB', 'fr-FR'])
expect(result.current).toEqual('en-GB')
})
it('falls back to the language when no browser entry matches', async () => {
const result = await renderWith('de', ['en-US', 'fr-FR'])
expect(result.current).toEqual('de')
})
it('keeps a language that already carries a region or script', async () => {
expect((await renderWith('pt-br', ['pt-PT'])).current).toEqual('pt-br')
expect((await renderWith('zh-Hans', ['zh-TW'])).current).toEqual('zh-Hans')
})
it('matches the browser language case-insensitively', async () => {
const result = await renderWith('pt', ['PT-PT'])
expect(result.current).toEqual('PT-PT')
})
it('returns undefined when there is no language', async () => {
const result = await renderWith(undefined, ['en-GB'])
expect(result.current).toBeUndefined()
})
})

View file

@ -9,6 +9,8 @@ import {
} from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { List, DateField } from '../common'
import { useDateLocale } from '../i18n/useDateLocale'
import { formatDateTime } from '../utils/formatters'
const UserFilter = (props) => (
<Filter {...props} variant={'outlined'}>
@ -18,6 +20,7 @@ const UserFilter = (props) => (
const UserList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const locale = useDateLocale()
return (
<List
@ -31,7 +34,7 @@ const UserList = (props) => {
<SimpleList
primaryText={(record) => record.userName}
secondaryText={(record) =>
record.lastLoginAt && new Date(record.lastLoginAt).toLocaleString()
record.lastLoginAt && formatDateTime(record.lastLoginAt, locale)
}
tertiaryText={(record) => (record.isAdmin ? '[admin]️' : '')}
/>

View file

@ -95,6 +95,9 @@ export const formatFullDate = (date, locale) => {
return new Date(date).toLocaleDateString(locale, options)
}
export const formatDateTime = (value, locale) =>
new Date(value).toLocaleString(locale)
export const formatNumber = (value, locale) => {
if (value === null || value === undefined) return '0'
return value.toLocaleString(locale)