Skip to content
Open
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
@@ -1,11 +1,40 @@
<script lang="ts">
import * as Tooltip from '$comp/ui/tooltip';
import { formatDateTime } from '$shared/dates';
import Time from 'svelte-time';

interface Props {
value: Date | string | undefined;
}

let { value }: Props = $props();

const date = $derived.by(() => {
if (!value) {
return undefined;
}

const parsedDate = value instanceof Date ? value : new Date(value);
return isNaN(parsedDate.getTime()) ? undefined : parsedDate;
});

const fullTimestamp = $derived(date ? formatDateTime(date) : '');
</script>

<Time live={true} relative={true} timestamp={value}></Time>
{#if date}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<span
{...props}
class="focus-visible:ring-ring inline cursor-help rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
Comment on lines +29 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the tooltip trigger to the keyboard tab order

When navigating event or stack lists with a keyboard, this custom trigger is a plain <span> without tabindex, so it cannot receive normal Tab focus and the full timestamp remains unavailable. The test's programmatic fireEvent.focus() succeeds even for non-focusable elements and therefore does not cover this scenario; make the trigger natively focusable or explicitly add it to the tab order.

Useful? React with 👍 / 👎.

>
<Time live={true} relative={true} timestamp={date}></Time>
</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content role="tooltip">{fullTimestamp}</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import { render, screen } from '@testing-library/svelte';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { tick } from 'svelte';
import Time from 'svelte-time';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';

import TimeAgo from './time-ago.svelte';

describe('TimeAgo', () => {
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
disconnect() {}
observe() {}
unobserve() {}
}
);
});

afterAll(() => {
vi.unstubAllGlobals();
});

afterEach(() => {
vi.useRealTimers();
});
Expand Down Expand Up @@ -37,4 +52,38 @@ describe('TimeAgo', () => {

expect(screen.getByText('an hour ago')).toBeTruthy();
});

it('exposes the full timestamp through an accessible tooltip', async () => {
const value = new Date('2026-08-11T12:34:56Z');
const { container } = render(TimeAgo, { value });

const trigger = container.querySelector<HTMLElement>('[data-slot="tooltip-trigger"]');
expect(trigger).not.toBeNull();
await fireEvent.focus(trigger!);

const tooltip = await screen.findByRole('tooltip');
const expectedTimestamp = new Intl.DateTimeFormat(undefined, {
day: 'numeric',
hour: 'numeric',
hour12: true,
minute: '2-digit',
month: 'short',
second: '2-digit',
timeZoneName: 'short',
year: 'numeric'
}).format(value);

expect(tooltip.textContent).toContain(expectedTimestamp);
});

it('omits missing and invalid timestamps', () => {
const missing = render(TimeAgo, { value: undefined });
expect(missing.container.textContent).toBe('');
expect(missing.container.querySelector('[data-slot="tooltip-trigger"]')).toBeNull();
missing.unmount();

const invalid = render(TimeAgo, { value: 'not a timestamp' });
expect(invalid.container.textContent).toBe('');
expect(invalid.container.querySelector('[data-slot="tooltip-trigger"]')).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { formatDateLabel, getDifferenceInSeconds, getRelativeTimeFormatUnit, getSetIntervalTime } from './dates';
import { formatDateLabel, formatDateTime, getDifferenceInSeconds, getRelativeTimeFormatUnit, getSetIntervalTime } from './dates';

describe('formatDateLabel', () => {
it('preserves local date and relative-label behavior without a timezone override', () => {
Expand Down Expand Up @@ -53,6 +53,21 @@ describe('formatDateLabel', () => {
});
});

describe('formatDateTime', () => {
it('keeps midnight and zero seconds instead of reducing the value to a date', () => {
const formatted = formatDateTime(new Date(2026, 0, 2, 0, 0, 0));
expect(formatted).toContain('2026');
expect(formatted).toContain('12:00:00');
});

it('preserves nonzero hours, minutes, seconds and the local timezone', () => {
const date = new Date(2026, 0, 2, 13, 4, 5);
const timezone = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' }).formatToParts(date).find((part) => part.type === 'timeZoneName')!.value;
expect(formatDateTime(date)).toContain('1:04:05');
expect(formatDateTime(date)).toContain(timezone);
});
});

const Time = {
days: (n: number) => n * 60 * 60 * 24,
hours: (n: number) => n * 60 * 60,
Expand Down
13 changes: 13 additions & 0 deletions src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ export function formatDateRangeLabel(start: Date, end: Date, currentDate: Date =
return `${startLabel} to ${endLabel}`;
}

export function formatDateTime(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
day: 'numeric',
hour: 'numeric',
hour12: true,
minute: '2-digit',
month: 'short',
second: '2-digit',
timeZoneName: 'short',
year: 'numeric'
}).format(date);
}

export function formatLongDate(value: Date): string {
return value.toLocaleDateString(undefined, {
day: 'numeric',
Expand Down
Loading