mirror of
https://github.com/vernu/textbee.git
synced 2026-07-30 17:07:46 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
61
web/components/shared/relative-time.test.tsx
Normal file
61
web/components/shared/relative-time.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
71
web/components/shared/relative-time.tsx
Normal file
71
web/components/shared/relative-time.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user