-
Notifications
You must be signed in to change notification settings - Fork 4
fix(platform): display timestamps in user's local timezone #700
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
2a12fe9
fix(platform): display timestamps in user's local timezone instead of…
Israeltheminer d26825d
fix(platform): run formatter on new CopyableTimestamp files
Israeltheminer 626195f
fix(platform): add hover-to-reveal copy button, aria-live region, tim…
Israeltheminer 647332b
fix(platform): show timezone abbreviation inline in CopyableTimestamp
Israeltheminer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
152 changes: 152 additions & 0 deletions
152
services/platform/app/components/ui/data-display/copyable-timestamp.stories.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.', | ||
| }, | ||
| }, | ||
| }, | ||
| }; |
148 changes: 148 additions & 0 deletions
148
services/platform/app/components/ui/data-display/copyable-timestamp.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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')} | ||
| 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> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.