Skip to content
Closed
Changes from all 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
227 changes: 159 additions & 68 deletions app/components/DateTime.vue
Original file line number Diff line number Diff line change
@@ -1,86 +1,177 @@
<script setup lang="ts">
/**
* DateTime component that wraps NuxtTime with settings-aware relative date support.
* Uses the global settings to determine whether to show relative or absolute dates.
*
* Note: When relativeDates setting is enabled, the component switches between
* relative and absolute display based on user preference. The title attribute
* always shows the full date for accessibility.
*/
const props = withDefaults(
defineProps<{
/** The datetime value (ISO string or Date) */
datetime: string | Date
/** Override title (defaults to datetime) */
title?: string
/** Date style for absolute display */
dateStyle?: 'full' | 'long' | 'medium' | 'short'
/** Individual date parts for absolute display (alternative to dateStyle) */
year?: 'numeric' | '2-digit'
month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow'
day?: 'numeric' | '2-digit'
}>(),
{
title: undefined,
dateStyle: undefined,
year: undefined,
month: undefined,
day: undefined,
},
)
import type { NuxtTimeProps } from 'nuxt/app'

const { locale } = useI18n()
interface DateTimeProps extends Omit<NuxtTimeProps, 'title' | 'relative' | 'locale'> {}

const props = withDefaults(defineProps<DateTimeProps>(), {
hour12: undefined,
})

const el = getCurrentInstance()?.vnode.el
const renderedDate = el?.getAttribute('datetime')
const _locale = el?.getAttribute('data-locale')

const nuxtApp = useNuxtApp()

const date = computed(() => {
const date = props.datetime
if (renderedDate && nuxtApp.isHydrating) {
return new Date(renderedDate)
}
if (!props.datetime) {
return new Date()
}
return new Date(date)
})

const now = ref(
import.meta.client && nuxtApp.isHydrating && window._nuxtTimeNow
? new Date(window._nuxtTimeNow)
: new Date(),
)

const relativeDates = useRelativeDates()

const dateFormatter = new Intl.DateTimeFormat(locale.value, {
if (import.meta.client && relativeDates.value) {
const handler = () => {
now.value = new Date()
}
const interval = setInterval(handler, 1000)
onBeforeUnmount(() => clearInterval(interval))
}

const { locale } = useI18n()
const defaults = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
}

const formatter = computed(() => {
const { relativeStyle, ...rest } = props
if (relativeDates.value) {
return new Intl.RelativeTimeFormat(_locale ?? locale.value, {
...defaults,
...rest,
style: relativeStyle,
})
}
return new Intl.DateTimeFormat(_locale ?? locale.value, { ...defaults, ...rest })
})
Comment on lines +53 to 63
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe we could avoid type assertions by doing:

Suggested change
const formatter = computed(() => {
const { relativeStyle, ...rest } = props
if (relativeDates.value) {
return new Intl.RelativeTimeFormat(_locale ?? locale.value, {
...defaults,
...rest,
style: relativeStyle,
})
}
return new Intl.DateTimeFormat(_locale ?? locale.value, { ...defaults, ...rest })
})
const relativeFormatter = computed(() => {
const { relativeStyle, ...rest } = props
return new Intl.RelativeTimeFormat(_locale ?? locale.value, {
...defaults,
...rest,
style: relativeStyle,
})
})
const formatter = computed(() => {
const { relativeStyle, ...rest } = props
return new Intl.DateTimeFormat(_locale ?? locale.value, { ...defaults, ...rest })
})

?


// Compute the title - always show full date for accessibility
const titleValue = computed(() => {
if (props.title) return props.title
const date = typeof props.datetime === 'string' ? new Date(props.datetime) : props.datetime
return dateFormatter.format(date)
const formattedDate = computed(() => {
if (!relativeDates.value) {
return (formatter.value as Intl.DateTimeFormat).format(date.value)
}

const diffInSeconds = (date.value.getTime() - now.value.getTime()) / 1000

const units: Array<{
unit: Intl.RelativeTimeFormatUnit
seconds: number
threshold: number
}> = [
{ unit: 'second', seconds: 1, threshold: 60 }, // 60 seconds → minute
{ unit: 'minute', seconds: 60, threshold: 60 }, // 60 minutes → hour
{ unit: 'hour', seconds: 3600, threshold: 24 }, // 24 hours → day
{ unit: 'day', seconds: 86400, threshold: 30 }, // ~30 days → month
{ unit: 'month', seconds: 2592000, threshold: 12 }, // 12 months → year
{ unit: 'year', seconds: 31536000, threshold: Infinity },
]

const { unit, seconds } =
units.find(({ seconds, threshold }) => Math.abs(diffInSeconds / seconds) < threshold) ||
units[units.length - 1]!

const value = diffInSeconds / seconds
return (formatter.value as Intl.RelativeTimeFormat).format(Math.round(value), unit)
})

const isoDate = computed(() => date.value.toISOString())
const dataset: Record<string, string | number | boolean | Date | undefined> = {}

if (import.meta.server) {
for (const prop in props) {
if (prop !== 'datetime') {
const value = props?.[prop as keyof typeof props]
if (value) {
const propInKebabCase = prop.split(/(?=[A-Z])/).join('-')
dataset[`data-${propInKebabCase}`] = props?.[prop as keyof typeof props]
}
}
}
onPrehydrate(el => {
const now = (window._nuxtTimeNow ||= Date.now())
// eslint-disable-next-line eslint-plugin-unicorn/consistent-function-scoping
const toCamelCase = (name: string, index: number) => {
if (index > 0) {
return name[0]!.toUpperCase() + name.slice(1)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
return name[0]!.toUpperCase() + name.slice(1)
return name.charAt(0).toUpperCase() + name.slice(1)

will avoid the need of non-null assertion

}
return name
}

const date = new Date(el.getAttribute('datetime')!)
el.title = date.toISOString()

const options: Intl.DateTimeFormatOptions &
Intl.RelativeTimeFormatOptions & { locale?: Intl.LocalesArgument; relative?: boolean } = {}
for (const name of el.getAttributeNames()) {
if (name.startsWith('data-')) {
let optionName = name
.slice(5)
.split('-')
.map(toCamelCase)
.join('') as keyof (Intl.DateTimeFormatOptions & Intl.RelativeTimeFormatOptions)

if ((optionName as string) === 'relativeStyle') {
optionName = 'style'
}
Comment on lines +123 to +131
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe just a tiny bit more accurate - still assertion needed but at least we're "telling the truth" here:

Suggested change
let optionName = name
.slice(5)
.split('-')
.map(toCamelCase)
.join('') as keyof (Intl.DateTimeFormatOptions & Intl.RelativeTimeFormatOptions)
if ((optionName as string) === 'relativeStyle') {
optionName = 'style'
}
let optionName = name.slice(5).split('-').map(toCamelCase).join('') as
| keyof (Intl.DateTimeFormatOptions & Intl.RelativeTimeFormatOptions)
| 'relativeStyle'
if (optionName === 'relativeStyle') {
optionName = 'style'
}


options[optionName] = el.getAttribute(name) as any
}
}

const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
const relative = settings.relativeDates
const locale = settings.selectedLocale

if (relative) {
const diffInSeconds = (date.getTime() - now) / 1000
const units: Array<{
unit: Intl.RelativeTimeFormatUnit
seconds: number
threshold: number
}> = [
{ unit: 'second', seconds: 1, threshold: 60 }, // 60 seconds → minute
{ unit: 'minute', seconds: 60, threshold: 60 }, // 60 minutes → hour
{ unit: 'hour', seconds: 3600, threshold: 24 }, // 24 hours → day
{ unit: 'day', seconds: 86400, threshold: 30 }, // ~30 days → month
{ unit: 'month', seconds: 2592000, threshold: 12 }, // 12 months → year
{ unit: 'year', seconds: 31536000, threshold: Infinity },
]
const { unit, seconds } =
units.find(({ seconds, threshold }) => Math.abs(diffInSeconds / seconds) < threshold) ||
units[units.length - 1]!
const value = diffInSeconds / seconds
const formatter = new Intl.RelativeTimeFormat(locale, options)
el.textContent = formatter.format(Math.round(value), unit)
} else {
const formatter = new Intl.DateTimeFormat(locale, options)
el.textContent = formatter.format(date)
}
})
}

declare global {
interface Window {
_nuxtTimeNow?: number
}
}
</script>

<template>
<span>
<ClientOnly>
<NuxtTime
v-if="relativeDates"
:datetime="datetime"
:title="titleValue"
relative
:locale="locale"
/>
<NuxtTime
v-else
:datetime="datetime"
:title="titleValue"
:date-style="dateStyle"
:year="year"
:month="month"
:day="day"
:locale="locale"
/>
<template #fallback>
<NuxtTime
:datetime="datetime"
:title="titleValue"
:date-style="dateStyle"
:year="year"
:month="month"
:day="day"
:locale="locale"
/>
</template>
</ClientOnly>
</span>
<time v-bind="dataset" :datetime="isoDate" :title="isoDate">{{ formattedDate }}</time>
</template>
Loading