diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte index 1c822955cc..d4ebf6529a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte @@ -1,4 +1,6 @@ - +{#if date} + + + + {#snippet child({ props })} + + + + {/snippet} + + {fullTimestamp} + + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte.test.ts index 07b5c4e611..af53d4730b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.svelte.test.ts @@ -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(); }); @@ -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('[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(); + }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.test.ts index 7cbae81e50..31d9f46663 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.test.ts @@ -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', () => { @@ -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, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.ts index c4072edcb9..71ff580999 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.ts @@ -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',