diff --git a/services/platform/app/components/ui/data-display/copyable-timestamp.stories.tsx b/services/platform/app/components/ui/data-display/copyable-timestamp.stories.tsx new file mode 100644 index 000000000..772e96b87 --- /dev/null +++ b/services/platform/app/components/ui/data-display/copyable-timestamp.stories.tsx @@ -0,0 +1,152 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { CopyableTimestamp } from './copyable-timestamp'; + +const NOW = Date.now(); +const YESTERDAY = NOW - 86_400_000; +const LAST_WEEK = NOW - 7 * 86_400_000; + +const meta: Meta = { + title: 'Data Display/CopyableTimestamp', + component: CopyableTimestamp, + tags: ['autodocs'], + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +Displays a formatted date with a copy-to-clipboard button that copies the raw +Unix millisecond timestamp. Intended for power users who need the exact value +for debugging or querying. + +The copy button is hidden by default and appears on hover/focus. + +## Usage +\`\`\`tsx +import { CopyableTimestamp } from '@/app/components/ui/data-display/copyable-timestamp'; + + +\`\`\` + +## Accessibility +- Copy button has an \`aria-label\` +- A screen-reader-only announcement is shown when copied +- Icons are decorative (\`aria-hidden\`) + `, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + date: NOW, + }, +}; + +export const ShortPreset: Story = { + args: { + date: NOW, + preset: 'short', + }, +}; + +export const LongPreset: Story = { + args: { + date: NOW, + preset: 'long', + }, +}; + +export const Yesterday: Story = { + args: { + date: YESTERDAY, + preset: 'short', + }, +}; + +export const LastWeek: Story = { + args: { + date: LAST_WEEK, + preset: 'medium', + }, +}; + +export const WithDateObject: Story = { + args: { + date: new Date(LAST_WEEK), + preset: 'long', + }, + parameters: { + docs: { + description: { + story: 'Accepts a Date object in addition to Unix ms or ISO strings.', + }, + }, + }, +}; + +export const Empty: Story = { + args: { + date: null, + }, + parameters: { + docs: { + description: { + story: 'Renders an em-dash when the date is null or undefined.', + }, + }, + }, +}; + +export const AlignRight: Story = { + render: () => ( +
+ +
+ ), + parameters: { + docs: { + description: { + story: 'Right-aligned for use in table columns.', + }, + }, + }, +}; + +export const InTableRow: Story = { + render: () => ( + + + + + + + + + {[ + { name: 'Design brief.pdf', date: NOW }, + { name: 'Q1 report.docx', date: YESTERDAY }, + { name: 'Onboarding guide.pdf', date: LAST_WEEK }, + ].map(({ name, date }) => ( + + + + + ))} + +
NameModified
{name} + +
+ ), + parameters: { + docs: { + description: { + story: 'Hover a row to reveal the copy button.', + }, + }, + }, +}; diff --git a/services/platform/app/components/ui/data-display/copyable-timestamp.tsx b/services/platform/app/components/ui/data-display/copyable-timestamp.tsx new file mode 100644 index 000000000..dbe189bb1 --- /dev/null +++ b/services/platform/app/components/ui/data-display/copyable-timestamp.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { Check, Copy } from 'lucide-react'; +import * as React from 'react'; + +import { useCopyButton } from '@/app/hooks/use-copy'; +import { useFormatDate } from '@/app/hooks/use-format-date'; +import { useT } from '@/lib/i18n/client'; +import { cn } from '@/lib/utils/cn'; +import { type DatePreset } from '@/lib/utils/date/format'; + +interface CopyableTimestampProps { + /** The date to display and copy (Unix ms, Date, or ISO string) */ + date: number | Date | string | null | undefined; + /** Format preset for display */ + preset?: DatePreset; + /** Additional className */ + className?: string; + /** Text to show when date is null/undefined */ + emptyText?: string; + /** Whether to align text to the right */ + alignRight?: boolean; +} + +/** + * Displays a formatted timestamp with a copy button that copies the raw + * Unix millisecond value to the clipboard — useful for power users who need + * the exact timestamp for debugging or querying. + * + * @example + * ```tsx + * + * ``` + */ +export const CopyableTimestamp = React.memo(function CopyableTimestamp({ + date, + preset = 'short', + className, + emptyText = '—', + alignRight = false, +}: CopyableTimestampProps) { + const { formatDate, timezone, timezoneShort } = useFormatDate(); + const { t: tCommon } = useT('common'); + + if (date === null || date === undefined) { + return ( + + {emptyText} + + ); + } + + const dateObj = + typeof date === 'number' || typeof date === 'string' + ? new Date(date) + : date; + + const timestampMs = String(dateObj.valueOf()); + const showTimezone = preset === 'long' || preset === 'time'; + const formatted = showTimezone + ? `${formatDate(dateObj, preset)} ${timezoneShort}` + : formatDate(dateObj, preset); + const titleText = `${formatDate(dateObj, 'long')} (${timezone})`; + + return ( + + ); +}); + +interface CopyableTimestampInnerProps { + timestampMs: string; + formatted: string; + titleText: string; + alignRight: boolean; + className?: string; + tCommon: (key: string) => string; +} + +function CopyableTimestampInner({ + timestampMs, + formatted, + titleText, + alignRight, + className, + tCommon, +}: CopyableTimestampInnerProps) { + const { copied, onClick } = useCopyButton(timestampMs); + + return ( + + + {formatted} + + + + {copied ? tCommon('actions.copied') : ''} + + + ); +} diff --git a/services/platform/app/features/documents/hooks/use-documents-table-config.tsx b/services/platform/app/features/documents/hooks/use-documents-table-config.tsx index 1a39a1345..f603f8cb5 100644 --- a/services/platform/app/features/documents/hooks/use-documents-table-config.tsx +++ b/services/platform/app/features/documents/hooks/use-documents-table-config.tsx @@ -9,8 +9,8 @@ import type { DocumentItem } from '@/types/documents'; import { OneDriveIcon } from '@/app/components/icons/onedrive-icon'; import { SharePointIcon } from '@/app/components/icons/sharepoint-icon'; +import { CopyableTimestamp } from '@/app/components/ui/data-display/copyable-timestamp'; import { DocumentIcon } from '@/app/components/ui/data-display/document-icon'; -import { TableDateCell } from '@/app/components/ui/data-display/table-date-cell'; import { Badge } from '@/app/components/ui/feedback/badge'; import { Skeleton } from '@/app/components/ui/feedback/skeleton'; import { HStack } from '@/app/components/ui/layout/layout'; @@ -260,9 +260,9 @@ export function useDocumentsTableConfig({ align: 'right' as const, }, cell: ({ row }) => ( - ), diff --git a/services/platform/app/hooks/use-format-date.ts b/services/platform/app/hooks/use-format-date.ts index 643cedd96..79b56e6d7 100644 --- a/services/platform/app/hooks/use-format-date.ts +++ b/services/platform/app/hooks/use-format-date.ts @@ -23,6 +23,19 @@ export function useFormatDate() { const { locale } = useLocale(); const { t } = useT('common'); + const timezone = useMemo( + () => Intl.DateTimeFormat().resolvedOptions().timeZone, + [], + ); + + const timezoneShort = useMemo( + () => + new Intl.DateTimeFormat(locale, { timeZoneName: 'short' }) + .formatToParts(new Date()) + .find((p) => p.type === 'timeZoneName')?.value ?? timezone, + [locale, timezone], + ); + const todayLabel = t('dates.today'); const yesterdayLabel = t('dates.yesterday'); @@ -37,9 +50,9 @@ export function useFormatDate() { preset: DatePreset = 'medium', options: Omit = {}, ): string => { - return formatDate(date, { ...options, preset, locale }); + return formatDate(date, { timezone, ...options, preset, locale }); }, - [locale], + [locale, timezone], ); const formatDateSmartWithLocale = useCallback( @@ -50,11 +63,11 @@ export function useFormatDate() { ): string => { return formatDateSmart( date, - { ...options, preset, locale }, + { timezone, ...options, preset, locale }, dateTranslations, ); }, - [locale, dateTranslations], + [locale, timezone, dateTranslations], ); const formatDateHeaderWithLocale = useCallback( @@ -62,16 +75,20 @@ export function useFormatDate() { date: string | Date | Dayjs, options: Omit = {}, ): string => { - return formatDateHeader(date, { ...options, locale }, dateTranslations); + return formatDateHeader( + date, + { timezone, ...options, locale }, + dateTranslations, + ); }, - [locale, dateTranslations], + [locale, timezone, dateTranslations], ); const formatRelative = useCallback( (date: string | Date | Dayjs): string => { - return formatDate(date, { preset: 'relative', locale }); + return formatDate(date, { preset: 'relative', locale, timezone }); }, - [locale], + [locale, timezone], ); return useMemo( @@ -81,6 +98,8 @@ export function useFormatDate() { formatDateHeader: formatDateHeaderWithLocale, formatRelative, locale, + timezone, + timezoneShort, }), [ formatDateWithLocale, @@ -88,6 +107,8 @@ export function useFormatDate() { formatDateHeaderWithLocale, formatRelative, locale, + timezone, + timezoneShort, ], ); }