feat: show relative timestamps with the exact time on hover

Device and API key cards rendered full timestamps ("Registered at: May 18,
2023, 1:42 PM"), which wrapped and dominated the cards at 375px.

New shared RelativeTime renders "7 days ago" with the exact date and time in a
tooltip. Formatting is delegated to date-fns, already a dependency and already
used by the webhook table, so no new package and no hand-rolled date parsing.
The only local rule is a "Just now" threshold under one minute.

Details:
- formatDistanceToNowStrict, not formatDistanceToNow, so it reads "3 minutes
  ago" rather than "about 3 minutes ago".
- Renders a real <time dateTime> element, keeping the machine-readable value
  in the DOM.
- The trigger is focusable, so the exact time is reachable by keyboard and not
  hover-only.
- Invalid or missing values render a caller-supplied fallback, so a key that
  has never been used still reads "Last used never" instead of "Invalid Date".

Labels shortened to match: "Registered at:" to "Registered", "Created at:" to
"Created".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
isra el
2026-07-18 21:27:21 +03:00
parent 1979e2f0b5
commit 9cb1213efc
4 changed files with 146 additions and 27 deletions

View File

@@ -29,6 +29,7 @@ import {
} from '@/lib/api'
import { Skeleton } from '@/components/ui/skeleton'
import EmptyState from '@/components/shared/empty-state'
import RelativeTime from '@/components/shared/relative-time'
import GenerateApiKey, {
type GenerateApiKeyHandle,
} from './generate-api-key'
@@ -217,23 +218,18 @@ export default function ApiKeys() {
</div>
<div className='flex items-center mt-1 space-x-3 text-xs text-muted-foreground'>
<div>
Created at:{' '}
{new Date(apiKey.createdAt).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
})}
Created <RelativeTime value={apiKey.createdAt} />
</div>
<div>
Last used:{' '}
{apiKey?.lastUsedAt && apiKey.usageCount
? new Date(apiKey.lastUsedAt).toLocaleString(
'en-US',
{
dateStyle: 'medium',
timeStyle: 'short',
}
)
: 'Never'}
Last used{' '}
<RelativeTime
value={
apiKey?.lastUsedAt && apiKey.usageCount
? apiKey.lastUsedAt
: null
}
fallback='never'
/>
</div>
</div>
</div>
@@ -351,13 +347,7 @@ export default function ApiKeys() {
{k.apiKey}
</code>
<div className='text-xs text-muted-foreground mt-1'>
Revoked{' '}
{k.revokedAt
? new Date(k.revokedAt).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
})
: ''}
Revoked <RelativeTime value={k.revokedAt} fallback='' />
</div>
</div>
<Button

View File

@@ -17,6 +17,7 @@ import { useToast } from '@/hooks/use-toast'
import { Routes } from '@/config/routes'
import { useDeleteDevice, useDevices, useSubscription } from '@/lib/api'
import EmptyState from '@/components/shared/empty-state'
import RelativeTime from '@/components/shared/relative-time'
import { useRef, useState } from 'react'
import {
Dialog,
@@ -256,11 +257,7 @@ export default function DeviceList() {
'unknown'}
</div>
<div>
Registered at:{' '}
{new Date(device.createdAt).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
})}
Registered <RelativeTime value={device.createdAt} />
</div>
</div>
{isDeviceOutdated(device as DeviceVersionCandidate) && (

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import RelativeTime, { toExactLabel, toRelativeLabel } from './relative-time'
const NOW = new Date('2026-07-18T12:00:00.000Z')
const ago = (ms: number) => new Date(NOW.getTime() - ms)
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
describe('toRelativeLabel', () => {
it('collapses anything under a minute to "Just now"', () => {
expect(toRelativeLabel(ago(0), NOW)).toBe('Just now')
expect(toRelativeLabel(ago(45 * SECOND), NOW)).toBe('Just now')
})
it('is strict, so it does not say "about"', () => {
// formatDistanceToNow would render "about 3 minutes ago".
expect(toRelativeLabel(ago(3 * MINUTE), NOW)).not.toContain('about')
})
})
describe('RelativeTime', () => {
it('renders a relative label for a past date', () => {
render(<RelativeTime value={ago(7 * DAY)} />)
expect(screen.getByText('7 days ago')).toBeInTheDocument()
})
it('exposes the machine-readable timestamp on a <time> element', () => {
const value = ago(2 * DAY)
const { container } = render(<RelativeTime value={value} />)
const el = container.querySelector('time')
expect(el).toBeInTheDocument()
expect(el).toHaveAttribute('dateTime', value.toISOString())
})
it('is reachable by keyboard so the exact time is not hover-only', () => {
const { container } = render(<RelativeTime value={ago(DAY)} />)
expect(container.querySelector('time')).toHaveAttribute('tabindex', '0')
})
it('renders the fallback for a null value', () => {
render(<RelativeTime value={null} fallback='Never' />)
expect(screen.getByText('Never')).toBeInTheDocument()
expect(document.querySelector('time')).toBeNull()
})
it('renders the fallback for an unparseable value rather than "Invalid Date"', () => {
render(<RelativeTime value='not-a-date' fallback='Unknown' />)
expect(screen.getByText('Unknown')).toBeInTheDocument()
})
it('formats the exact label with both date and time', () => {
// Constructed from parts so the assertion does not depend on the TZ the
// suite happens to run in.
const date = new Date(2023, 4, 18, 13, 42)
expect(toExactLabel(date)).toBe('May 18, 2023 at 1:42 PM')
})
})

View File

@@ -0,0 +1,71 @@
'use client'
import { format, formatDistanceToNowStrict, isValid } from 'date-fns'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
// Anything under this reads as "Just now" rather than "34 seconds ago".
const JUST_NOW_MS = 60 * 1000
export function toRelativeLabel(date: Date, now: Date = new Date()): string {
if (now.getTime() - date.getTime() < JUST_NOW_MS) return 'Just now'
// Strict, so it says "3 minutes ago" instead of "about 3 minutes ago".
return formatDistanceToNowStrict(date, { addSuffix: true })
}
export function toExactLabel(date: Date): string {
return format(date, "MMM d, yyyy 'at' h:mm a")
}
/**
* A timestamp shown as "7 days ago", with the exact date and time on hover.
*
* Long absolute timestamps ("May 18, 2023, 1:42 PM") wrapped and dominated the
* device and API key cards at mobile widths. Formatting is delegated to
* date-fns; the only local rule is the "Just now" threshold.
*/
export default function RelativeTime({
value,
fallback = '-',
className,
}: {
value: string | number | Date | null | undefined
fallback?: string
className?: string
}) {
if (value === null || value === undefined || value === '') {
return <span className={className}>{fallback}</span>
}
const date = value instanceof Date ? value : new Date(value)
if (!isValid(date)) {
return <span className={className}>{fallback}</span>
}
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
{/* A real <time> keeps the machine-readable value in the DOM, and
tabIndex means keyboard users can reach the exact timestamp too. */}
<time
dateTime={date.toISOString()}
tabIndex={0}
className={cn(
'cursor-default underline decoration-dotted underline-offset-2',
className
)}
>
{toRelativeLabel(date)}
</time>
</TooltipTrigger>
<TooltipContent>{toExactLabel(date)}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}