Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<typeof CopyableTimestamp> = {
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';

<CopyableTimestamp date={document.lastModified} preset="short" alignRight />
\`\`\`

## 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<typeof CopyableTimestamp>;

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: () => (
<div className="w-48 rounded border p-2">
<CopyableTimestamp date={NOW} preset="short" alignRight />
</div>
),
parameters: {
docs: {
description: {
story: 'Right-aligned for use in table columns.',
},
},
},
};

export const InTableRow: Story = {
render: () => (
<table className="border-collapse text-sm">
<thead>
<tr>
<th className="border p-2 text-left">Name</th>
<th className="border p-2 text-right">Modified</th>
</tr>
</thead>
<tbody>
{[
{ name: 'Design brief.pdf', date: NOW },
{ name: 'Q1 report.docx', date: YESTERDAY },
{ name: 'Onboarding guide.pdf', date: LAST_WEEK },
].map(({ name, date }) => (
<tr key={name}>
<td className="border p-2">{name}</td>
<td className="border p-2">
<CopyableTimestamp date={date} preset="short" alignRight />
</td>
</tr>
))}
</tbody>
</table>
),
parameters: {
docs: {
description: {
story: 'Hover a row to reveal the copy button.',
},
},
},
};
Original file line number Diff line number Diff line change
@@ -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
* <CopyableTimestamp date={row.original.lastModified} preset="short" alignRight />
* ```
*/
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 (
<span
className={cn(
'text-sm text-muted-foreground whitespace-nowrap',
alignRight && 'text-right block',
className,
)}
>
{emptyText}
</span>
);
}

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 (
<CopyableTimestampInner
timestampMs={timestampMs}
formatted={formatted}
titleText={titleText}
alignRight={alignRight}
className={className}
tCommon={tCommon}
/>
);
});

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 (
<span
className={cn(
'group inline-flex items-center gap-1',
alignRight && 'justify-end w-full',
className,
)}
>
<span
className="text-muted-foreground text-sm whitespace-nowrap"
title={titleText}
>
{formatted}
</span>
<button
type="button"
className={cn(
'shrink-0 cursor-pointer rounded p-0.5 transition-colors',
'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
'hover:bg-muted',
)}
aria-label={tCommon('actions.copy')}
Comment thread
Israeltheminer marked this conversation as resolved.
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
{copied ? (
<Check
className="size-3.5 text-green-600 dark:text-green-400"
aria-hidden="true"
/>
) : (
<Copy className="text-muted-foreground size-3.5" aria-hidden="true" />
)}
</button>
<span
className="sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>
{copied ? tCommon('actions.copied') : ''}
</span>
</span>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -260,9 +260,9 @@ export function useDocumentsTableConfig({
align: 'right' as const,
},
cell: ({ row }) => (
<TableDateCell
<CopyableTimestamp
date={row.original.lastModified}
preset="short"
preset="long"
alignRight
/>
),
Expand Down
Loading
Loading