Skip to content

Commit 5f16bc6

Browse files
committed
User listing table date improvements
1 parent 881302d commit 5f16bc6

File tree

34 files changed

+436
-76
lines changed

34 files changed

+436
-76
lines changed

app/components/badge/badge-condition.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DateFormatter } from '@/components/date';
1+
import { DateTime } from '@/components/date';
22
import { cn } from '@/modules/shadcn/lib/utils';
33
import { Badge } from '@/modules/shadcn/ui/badge';
44
import { ControlPlaneStatus } from '@/resources/schemas';
@@ -101,7 +101,7 @@ function createTooltipContent(title: string, message: string, lastTransitionTime
101101
<div className="mt-1 text-sm">{message}</div>
102102
{lastTransitionTime && (
103103
<div className="mt-1 text-xs opacity-60">
104-
Last transition: <DateFormatter date={lastTransitionTime} withTime />
104+
Last transition: <DateTime date={lastTransitionTime} tooltip={false} />
105105
</div>
106106
)}
107107
</div>

app/components/date/date-time.tsx

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import {
2+
formatAbsoluteDate,
3+
formatCombinedDate,
4+
formatRelativeDate,
5+
formatTimezoneDate,
6+
formatUTCDate,
7+
getTimestamp,
8+
getTimezoneAbbreviation,
9+
parseDate,
10+
} from './formatters';
11+
import type { DateTimeProps, FormatterOptions } from './types';
12+
import { cn } from '@/modules/shadcn/lib/utils';
13+
import { useApp } from '@/providers/app.provider';
14+
import { getBrowserTimezone } from '@/utils/helpers/timezone.helper';
15+
import { Tooltip } from '@datum-ui/tooltip';
16+
import { useEffect, useState } from 'react';
17+
18+
/**
19+
* Unified component for displaying dates in absolute, relative, or combined formats
20+
* with intelligent tooltip support and timezone awareness.
21+
*
22+
* @example
23+
* // Absolute date
24+
* <DateTime date={createdAt} />
25+
*
26+
* @example
27+
* // Relative time
28+
* <DateTime date={createdAt} variant="relative" />
29+
*
30+
* @example
31+
* // Combined format
32+
* <DateTime date={createdAt} variant="both" />
33+
*/
34+
export const DateTime = ({
35+
date,
36+
variant = 'detailed',
37+
format,
38+
addSuffix,
39+
tooltip = 'auto',
40+
timezone,
41+
disableTimezone = false,
42+
className,
43+
separator = ' ',
44+
disableHydrationProtection = false,
45+
showTooltip = true, // Legacy prop from DateFormat
46+
}: DateTimeProps) => {
47+
const { settings } = useApp();
48+
const [mounted, setMounted] = useState(false);
49+
50+
// Hydration protection for relative dates (client-side only)
51+
const needsHydrationProtection = variant === 'relative' || variant === 'both';
52+
53+
useEffect(() => {
54+
if (needsHydrationProtection && !disableHydrationProtection) {
55+
setMounted(true);
56+
}
57+
}, [needsHydrationProtection, disableHydrationProtection]);
58+
59+
if (!date) {
60+
return null;
61+
}
62+
63+
// Parse and validate date
64+
const parsedDate = parseDate(date);
65+
66+
if (!parsedDate) {
67+
return null;
68+
}
69+
70+
// Show loading state during hydration
71+
if (needsHydrationProtection && !disableHydrationProtection && !mounted) {
72+
return <span className={className}>...</span>;
73+
}
74+
75+
// Prepare formatter options
76+
const timeZone = timezone ?? settings?.timezone ?? getBrowserTimezone();
77+
const formatterOptions: FormatterOptions = {
78+
timezone: timeZone,
79+
disableTimezone,
80+
format,
81+
addSuffix,
82+
};
83+
84+
// Format content based on variant
85+
let content: string;
86+
switch (variant) {
87+
case 'detailed':
88+
content = formatTimezoneDate(parsedDate, timeZone);
89+
break;
90+
case 'relative':
91+
content = formatRelativeDate(parsedDate, formatterOptions);
92+
break;
93+
case 'both':
94+
content = formatCombinedDate(parsedDate, formatterOptions, separator);
95+
break;
96+
case 'absolute':
97+
default:
98+
content = formatAbsoluteDate(parsedDate, formatterOptions);
99+
break;
100+
}
101+
102+
// Determine tooltip behavior
103+
const shouldShowTooltip = determineTooltipVisibility(tooltip, showTooltip);
104+
105+
if (!shouldShowTooltip || disableTimezone) {
106+
return <span className={cn(className)}>{content}</span>;
107+
}
108+
109+
// Determine tooltip content
110+
const tooltipContent = getTooltipContent(
111+
parsedDate,
112+
variant,
113+
tooltip,
114+
formatterOptions,
115+
timeZone
116+
);
117+
118+
return (
119+
<Tooltip message={tooltipContent}>
120+
<span className={cn('cursor-pointer', className)}>{content}</span>
121+
</Tooltip>
122+
);
123+
};
124+
125+
/**
126+
* Determines if tooltip should be shown
127+
*/
128+
function determineTooltipVisibility(
129+
tooltip: DateTimeProps['tooltip'],
130+
showTooltip: boolean
131+
): boolean {
132+
if (typeof tooltip === 'boolean') {
133+
return tooltip;
134+
}
135+
136+
// Legacy support for showTooltip prop
137+
if (tooltip === 'auto' && !showTooltip) {
138+
return false;
139+
}
140+
141+
// Auto mode shows tooltip by default
142+
return true;
143+
}
144+
145+
/**
146+
* Gets the appropriate tooltip content based on variant and mode
147+
*/
148+
function getTooltipContent(
149+
date: Date,
150+
variant: DateTimeProps['variant'],
151+
tooltip: DateTimeProps['tooltip'],
152+
options: FormatterOptions,
153+
timeZone: string
154+
): React.ReactNode {
155+
// Detailed variant - show all time formats
156+
if (variant === 'detailed') {
157+
const utcTime = formatUTCDate(date);
158+
const timezoneTime = formatTimezoneDate(date, timeZone);
159+
const relativeTime = formatRelativeDate(date, options);
160+
const timestamp = getTimestamp(date);
161+
162+
const rows = [
163+
{ label: 'UTC', value: utcTime },
164+
{ label: timeZone.replace('_', ' '), value: timezoneTime },
165+
{ label: 'Relative', value: relativeTime },
166+
{ label: 'Timestamp', value: timestamp },
167+
];
168+
169+
return (
170+
<div className="space-y-2 text-xs">
171+
{rows.map((row) => (
172+
<div key={row.label} className="flex items-center justify-between gap-2">
173+
<span className="font-medium">{row.label}</span>
174+
<span className="mx-1 flex-1 border-b border-dotted border-current/50" />
175+
<span className="text-right">{row.value}</span>
176+
</div>
177+
))}
178+
</div>
179+
);
180+
}
181+
182+
// Explicit timezone mode
183+
if (tooltip === 'timezone') {
184+
return (
185+
<p>
186+
{timeZone.replace('_', ' ')}&nbsp; ({getTimezoneAbbreviation(date, timeZone)})
187+
</p>
188+
);
189+
}
190+
191+
// Alternate mode - show opposite format
192+
if (tooltip === 'alternate') {
193+
if (variant === 'relative') {
194+
return formatAbsoluteDate(date, options);
195+
}
196+
if (variant === 'absolute' || variant === 'both') {
197+
return formatRelativeDate(date, options);
198+
}
199+
}
200+
201+
// Auto mode - intelligent defaults
202+
if (tooltip === 'auto' || tooltip === true) {
203+
switch (variant) {
204+
case 'relative':
205+
// Show absolute date for relative time
206+
return formatAbsoluteDate(date, options);
207+
208+
case 'both':
209+
case 'absolute':
210+
default:
211+
// Show timezone info for absolute dates
212+
return (
213+
<p>
214+
{timeZone.replace('_', ' ')}&nbsp; ({getTimezoneAbbreviation(date, timeZone)})
215+
</p>
216+
);
217+
}
218+
}
219+
220+
return null;
221+
}

app/components/date/formatters.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { FormatterOptions } from './types';
2+
import { format as dateFormat, formatDistanceToNowStrict } from 'date-fns';
3+
import { formatInTimeZone } from 'date-fns-tz';
4+
import { enUS } from 'date-fns/locale/en-US';
5+
6+
export const DEFAULT_DATE_FORMAT = 'MMMM d, yyyy hh:mmaaa';
7+
8+
/**
9+
* Parses a date string or Date object into a valid Date
10+
*/
11+
export function parseDate(date: string | Date): Date | null {
12+
const parsedDate = date instanceof Date ? date : new Date(date);
13+
14+
if (!date || isNaN(parsedDate.getTime())) {
15+
return null;
16+
}
17+
18+
return parsedDate;
19+
}
20+
21+
/**
22+
* Formats an absolute date with timezone support
23+
*/
24+
export function formatAbsoluteDate(date: Date, options: FormatterOptions): string {
25+
const formatString = options.format || DEFAULT_DATE_FORMAT;
26+
27+
if (options.disableTimezone) {
28+
return dateFormat(date, formatString, { locale: enUS });
29+
}
30+
31+
return formatInTimeZone(date, options.timezone, formatString, { locale: enUS });
32+
}
33+
34+
/**
35+
* Formats a relative date ("X ago")
36+
* Note: Relative time is always absolute (timezone-independent) because
37+
* "2 hours ago" represents a duration, not a point in time.
38+
* It should be the same regardless of which timezone the user prefers.
39+
*/
40+
export function formatRelativeDate(date: Date, options: FormatterOptions): string {
41+
// Always use the original UTC date for relative calculations
42+
// Timezone conversion would create incorrect offsets when comparing to browser's "now"
43+
return formatDistanceToNowStrict(date, {
44+
addSuffix: options.addSuffix ?? true,
45+
});
46+
}
47+
48+
/**
49+
* Gets the timezone abbreviation (e.g., "PST", "EST")
50+
*/
51+
export function getTimezoneAbbreviation(date: Date, timezone: string): string {
52+
return formatInTimeZone(date, timezone, 'zzz', { locale: enUS });
53+
}
54+
55+
/**
56+
* Formats a combined display (absolute + relative)
57+
*/
58+
export function formatCombinedDate(
59+
date: Date,
60+
options: FormatterOptions,
61+
separator: string = ' '
62+
): string {
63+
const absolute = formatAbsoluteDate(date, options);
64+
const relative = formatRelativeDate(date, options);
65+
66+
return `${absolute}${separator}(${relative})`;
67+
}
68+
69+
/**
70+
* Formats a date in UTC timezone (for detailed popup)
71+
*/
72+
export function formatUTCDate(date: Date): string {
73+
return formatInTimeZone(date, 'UTC', 'dd MMM yy HH:mm:ss', { locale: enUS });
74+
}
75+
76+
/**
77+
* Formats a date in a specific timezone (for detailed popup)
78+
*/
79+
export function formatTimezoneDate(date: Date, timezone: string): string {
80+
return formatInTimeZone(date, timezone, 'dd MMM yy HH:mm:ss', { locale: enUS });
81+
}
82+
83+
/**
84+
* Gets the raw timestamp in microseconds (for detailed popup)
85+
*/
86+
export function getTimestamp(date: Date): string {
87+
// Convert to microseconds (multiply milliseconds by 1000)
88+
return (date.getTime() * 1000).toString();
89+
}

app/components/date/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
export { default as DateFormatter } from './date-formatter';
21
export { DateRangePicker } from './date-range-picker';
2+
export { DateTime } from './date-time';
3+
export type { DateTimeProps } from './types';

app/components/date/types.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
export type DateTimeVariant = 'absolute' | 'relative' | 'both' | 'detailed';
2+
3+
export type TooltipMode = boolean | 'auto' | 'timezone' | 'alternate';
4+
5+
export interface DateTimeProps {
6+
/** The date to format - can be a Date object or ISO string */
7+
date?: string | Date;
8+
9+
/** Display variant - absolute shows formatted date, relative shows "X ago", both shows combined */
10+
variant?: DateTimeVariant;
11+
12+
/** Custom format string for absolute dates (uses date-fns format tokens) */
13+
format?: string;
14+
15+
/** Add "ago" suffix for relative dates */
16+
addSuffix?: boolean;
17+
18+
/** Tooltip behavior:
19+
* - true/false: show/hide tooltip
20+
* - 'auto': intelligent default based on variant
21+
* - 'timezone': show timezone info
22+
* - 'alternate': show opposite format
23+
*/
24+
tooltip?: TooltipMode;
25+
26+
/** Custom timezone (defaults to user preference or UTC) */
27+
timezone?: string;
28+
29+
/** Disable timezone conversion */
30+
disableTimezone?: boolean;
31+
32+
/** CSS class name */
33+
className?: string;
34+
35+
/** Separator text when variant="both" */
36+
separator?: string;
37+
38+
/** Disable hydration mismatch protection (useful for SSR) */
39+
disableHydrationProtection?: boolean;
40+
41+
/** Hide tooltip (legacy prop from DateFormat) */
42+
showTooltip?: boolean;
43+
}
44+
45+
export interface FormatterOptions {
46+
timezone: string;
47+
disableTimezone: boolean;
48+
format?: string;
49+
addSuffix?: boolean;
50+
}

app/features/activity/components/activity-list.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BadgeState } from '@/components/badge';
2-
import { DateFormatter, DateRangePicker } from '@/components/date';
2+
import { DateTime, DateRangePicker } from '@/components/date';
33
import { ActivityLogEntry } from '@/modules/loki';
44
import { useApp } from '@/providers/app.provider';
55
import { activityListQuery } from '@/resources/request/client';
@@ -99,7 +99,7 @@ const createColumns = () => [
9999
columnHelper.accessor('timestamp', {
100100
header: () => <Trans>Timestamp</Trans>,
101101
cell: ({ getValue }) => (
102-
<Tooltip message={<DateFormatter date={getValue()} withTime />}>
102+
<Tooltip message={<DateTime date={getValue()} />}>
103103
<span>{formatDistanceToNowStrict(new Date(getValue()), { addSuffix: true })}</span>
104104
</Tooltip>
105105
),

0 commit comments

Comments
 (0)