index.tsx192 lines · main
1'use client'
2
3import dayjs from 'dayjs'
4import relativeTime from 'dayjs/plugin/relativeTime'
5import timezone from 'dayjs/plugin/timezone'
6import utc from 'dayjs/plugin/utc'
7import { useEffect, useRef, useState } from 'react'
8import { cn, copyToClipboard, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
9
10import { useTimestampInfoContext } from './TimestampInfoProvider'
11
12export { TimestampInfoProvider } from './TimestampInfoProvider'
13
14dayjs.extend(relativeTime)
15dayjs.extend(utc)
16dayjs.extend(timezone)
17
18const unixMicroToIsoTimestamp = (unix: string | number): string => {
19 return dayjs.unix(Number(unix) / 1000 / 1000).toISOString()
20}
21
22const isUnixMicro = (unix: string | number): boolean => {
23 const digitLength = String(unix).length === 16
24 const isNum = !Number.isNaN(Number(unix))
25 return isNum && digitLength
26}
27
28type TimestampFormatter = {
29 utcTimestamp: string | number
30 format?: string
31 /**
32 * IANA timezone to render the timestamp in. When omitted, falls back to the
33 * browser's local timezone (preserving the historical default).
34 */
35 timezone?: string
36}
37
38export const timestampLocalFormatter = ({ utcTimestamp, format, timezone }: TimestampFormatter) => {
39 const timestamp = isUnixMicro(utcTimestamp) ? unixMicroToIsoTimestamp(utcTimestamp) : utcTimestamp
40 const base = dayjs.utc(timestamp)
41 const localised = timezone ? base.tz(timezone) : base.local()
42 return localised.format(format)
43}
44
45const timestampUtcFormatter = ({ utcTimestamp, format }: TimestampFormatter) => {
46 const timestamp = isUnixMicro(utcTimestamp) ? unixMicroToIsoTimestamp(utcTimestamp) : utcTimestamp
47 return dayjs.utc(timestamp).format(format)
48}
49
50const timestampRelativeFormatter = ({ utcTimestamp }: TimestampFormatter) => {
51 const timestamp = isUnixMicro(utcTimestamp) ? unixMicroToIsoTimestamp(utcTimestamp) : utcTimestamp
52 return dayjs.utc(timestamp).fromNow()
53}
54
55/**
56 * TimestampInfo component displays a timestamp with a tooltip showing various time formats.
57 * @param {string|number} props.utcTimestamp - UTC timestamp value. Can be either:
58 * - ISO 8601 string (e.g., "2024-01-01T00:00:00Z")
59 * - Unix microseconds (16-digit number)
60 * @param {string} [props.format="DD MMM HH:mm:ss"] - Display format for the timestamp (using dayjs format)
61 * @returns {JSX.Element} Timestamp display with tooltip showing UTC, local, relative, and raw timestamp values
62 */
63export const TimestampInfo = ({
64 utcTimestamp,
65 className,
66 displayAs = 'local',
67 format = 'DD MMM YY HH:mm:ss',
68 labelFormat = 'DD MMM YY HH:mm:ss',
69 label,
70 timezone: timezoneProp,
71}: {
72 className?: string
73 utcTimestamp: string | number
74 displayAs?: 'local' | 'utc'
75 format?: string
76 labelFormat?: string
77 label?: string
78 /**
79 * IANA timezone to render the timestamp in. When omitted the component
80 * falls back to the value supplied by `TimestampInfoProvider`, then to the
81 * browser's local timezone.
82 */
83 timezone?: string
84}) => {
85 const { timezone: timezoneFromContext } = useTimestampInfoContext()
86 const effectiveTimezone = timezoneProp ?? timezoneFromContext
87 const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
88 const local = timestampLocalFormatter({ utcTimestamp, format, timezone: effectiveTimezone })
89 const browserLocal = timestampLocalFormatter({ utcTimestamp, format })
90 const utc = timestampUtcFormatter({ utcTimestamp, format })
91 const relative = timestampRelativeFormatter({ utcTimestamp })
92 const [align, setAlign] = useState<'start' | 'end'>('start')
93 const triggerRef = useRef<HTMLButtonElement>(null)
94 const localTimezone = effectiveTimezone ?? browserTimezone
95 // Show the browser timezone as a separate row whenever the user has
96 // overridden it via the picker, so they don't lose context for "what time
97 // is it where I am" while reading logs in another timezone.
98 const showBrowserRow = !!effectiveTimezone && effectiveTimezone !== browserTimezone
99
100 // Calculate alignment based on trigger position
101 // Needed so that the tooltip isn't hidden behind the header on top rows (in logs)
102 useEffect(() => {
103 const updateAlignment = () => {
104 if (triggerRef.current) {
105 const rect = triggerRef.current.getBoundingClientRect()
106 const windowHeight = window.innerHeight
107 setAlign(rect.top < windowHeight / 2 ? 'start' : 'end')
108 }
109 }
110
111 updateAlignment()
112 window.addEventListener('scroll', updateAlignment)
113 window.addEventListener('resize', updateAlignment)
114
115 return () => {
116 window.removeEventListener('scroll', updateAlignment)
117 window.removeEventListener('resize', updateAlignment)
118 }
119 }, [])
120
121 const TooltipRow = ({ label, value }: { label: string; value: string }) => {
122 const [copied, setCopied] = useState(false)
123
124 return (
125 <div
126 onPointerDown={(e) => {
127 e.stopPropagation()
128 }}
129 onMouseDown={(e) => {
130 e.stopPropagation()
131 }}
132 onClick={(e) => {
133 e.stopPropagation()
134 e.preventDefault()
135 copyToClipboard(value, () => {
136 setCopied(true)
137 setTimeout(() => {
138 setCopied(false)
139 }, 1000)
140 })
141 }}
142 className={cn(
143 'relative cursor-pointer flex gap-y-2 gap-x-0.5 hover:bg-surface-100 px-2 py-1 group',
144 { 'bg-surface-100': copied }
145 )}
146 >
147 <div className="flex items-center gap-x-2 text-left truncate">
148 <p>{label}</p>
149 <div className="border-t w-full border-dashed" />
150 </div>
151 <div className="relative flex items-center gap-x-2 grow">
152 <div className="border-t w-full border-dashed z-10" />
153 {copied && (
154 <span className="flex items-center justify-end w-full absolute inset-0 flex items-right text-brand-600 bg-surface-100">
155 Copied!
156 </span>
157 )}
158 <span className="flex items-center gap-x-2 justify-end whitespace-nowrap">{value}</span>
159 </div>
160 </div>
161 )
162 }
163
164 return (
165 <Tooltip>
166 <TooltipTrigger
167 asChild
168 ref={triggerRef}
169 className={`text-xs ${className} border-b border-transparent hover:border-dashed hover:border-foreground-light`}
170 >
171 <span>
172 {label
173 ? label
174 : displayAs === 'local'
175 ? timestampLocalFormatter({
176 utcTimestamp,
177 format: labelFormat,
178 timezone: effectiveTimezone,
179 })
180 : timestampUtcFormatter({ utcTimestamp, format: labelFormat })}
181 </span>
182 </TooltipTrigger>
183 <TooltipContent align={align} side="right" className="font-mono p-0 py-1 min-w-80">
184 <TooltipRow label="UTC" value={utc} />
185 <TooltipRow label={localTimezone} value={local} />
186 {showBrowserRow && <TooltipRow label={browserTimezone} value={browserLocal} />}
187 <TooltipRow label="Relative" value={relative} />
188 <TooltipRow label="Timestamp" value={String(utcTimestamp)} />
189 </TooltipContent>
190 </Tooltip>
191 )
192}