index.tsx192 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import dayjs from 'dayjs' |
| 4 | import relativeTime from 'dayjs/plugin/relativeTime' |
| 5 | import timezone from 'dayjs/plugin/timezone' |
| 6 | import utc from 'dayjs/plugin/utc' |
| 7 | import { useEffect, useRef, useState } from 'react' |
| 8 | import { cn, copyToClipboard, Tooltip, TooltipContent, TooltipTrigger } from 'ui' |
| 9 | |
| 10 | import { useTimestampInfoContext } from './TimestampInfoProvider' |
| 11 | |
| 12 | export { TimestampInfoProvider } from './TimestampInfoProvider' |
| 13 | |
| 14 | dayjs.extend(relativeTime) |
| 15 | dayjs.extend(utc) |
| 16 | dayjs.extend(timezone) |
| 17 | |
| 18 | const unixMicroToIsoTimestamp = (unix: string | number): string => { |
| 19 | return dayjs.unix(Number(unix) / 1000 / 1000).toISOString() |
| 20 | } |
| 21 | |
| 22 | const 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 | |
| 28 | type 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 | |
| 38 | export 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 | |
| 45 | const timestampUtcFormatter = ({ utcTimestamp, format }: TimestampFormatter) => { |
| 46 | const timestamp = isUnixMicro(utcTimestamp) ? unixMicroToIsoTimestamp(utcTimestamp) : utcTimestamp |
| 47 | return dayjs.utc(timestamp).format(format) |
| 48 | } |
| 49 | |
| 50 | const 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 | */ |
| 63 | export 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 | } |