From 24365bd3bda413a98cc3bd0c6946b2607537118e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 15 Sep 2026 21:37:07 -0500 Subject: [PATCH 1/7] Show full timestamps in relative-time tooltips --- .../components/formatters/time-ago.svelte | 31 ++++++++++- .../formatters/time-ago.svelte.test.ts | 53 ++++++++++++++++++- .../src/lib/features/shared/dates.test.ts | 17 +++++- .../src/lib/features/shared/dates.ts | 13 +++++ 4 files changed, 110 insertions(+), 4 deletions(-) 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', From 432abba7de281cf86050dfa6f7df9d643a80940b Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 19 Sep 2026 22:33:27 -0500 Subject: [PATCH 2/7] Compose timestamp tooltips from existing formatters --- .../e2e/tests/timestamp-tooltips.e2e.ts | 57 ++++++++++++++++ .../components/formatters/date-time.svelte | 9 ++- .../formatters/date-time.svelte.test.ts | 67 +++++++++++++++++++ .../components/formatters/time-ago.svelte | 41 +++++++----- .../formatters/time-ago.svelte.test.ts | 32 ++++++--- .../formatters/time-ago.test-harness.svelte | 15 +++++ .../src/lib/features/shared/dates.test.ts | 17 +---- .../src/lib/features/shared/dates.ts | 13 ---- 8 files changed, 191 insertions(+), 60 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/timestamp-tooltips.e2e.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/date-time.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.test-harness.svelte diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/timestamp-tooltips.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/timestamp-tooltips.e2e.ts new file mode 100644 index 0000000000..6ecd3b715a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/timestamp-tooltips.e2e.ts @@ -0,0 +1,57 @@ +import { expect, test } from '../fixtures/e2e-test'; + +test.use({ locale: 'en-US', timezoneId: 'UTC' }); + +test('event and stack timestamps expose full time through keyboard navigation and hover', async ({ e2eApi, e2eScenario, page }) => { + // Arrange + const date = new Date(); + date.setUTCHours(0, 0, 0, 0); + await e2eApi.submitEvent(e2eScenario.projectId, e2eScenario.projectToken, { + date: date.toISOString(), + message: 'Synthetic timestamp verification', + reference_id: e2eScenario.referenceId, + type: 'error' + }); + await e2eApi.pollForEventByReference(e2eScenario.userToken, e2eScenario.projectId, e2eScenario.referenceId); + + for (const route of ['/next/event?time=all', '/next/stack?time=all']) { + await test.step(`Tab to the timestamp on ${route}`, async () => { + // Arrange + await page.goto(route); + const timestamp = page.locator('time[data-slot="tooltip-trigger"]').first(); + await expect(timestamp).toBeVisible(); + + // Act + for (let attempt = 0; attempt < 100; attempt++) { + await page.keyboard.press('Tab'); + if (await timestamp.evaluate((element) => element === document.activeElement)) { + break; + } + } + + // Assert + await expect(timestamp).toBeFocused(); + await expect(timestamp).not.toHaveAttribute('title'); + await expect(page.getByRole('tooltip')).toContainText('12:00:00 AM UTC'); + await expect(page.getByRole('tooltip')).toContainText(String(date.getUTCFullYear())); + }); + + await test.step('Escape dismisses the tooltip and hover reopens it', async () => { + // Arrange + const timestamp = page.locator('time[data-slot="tooltip-trigger"]').first(); + + // Act + await page.keyboard.press('Escape'); + + // Assert + await expect(page.getByRole('tooltip')).toBeHidden(); + + // Act + await page.keyboard.press('Tab'); + await timestamp.hover(); + + // Assert + await expect(page.getByRole('tooltip')).toContainText('12:00:00 AM UTC'); + }); + } +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/date-time.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/date-time.svelte index 9a64fca082..92bc04aaea 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/date-time.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/date-time.svelte @@ -1,9 +1,10 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/date-time.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/date-time.svelte.test.ts new file mode 100644 index 0000000000..e74947b769 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/date-time.svelte.test.ts @@ -0,0 +1,67 @@ +import { render } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; + +import DateTime from './date-time.svelte'; + +describe('DateTime', () => { + it('renders midnight with seconds and the requested timezone', () => { + // Arrange + const value = new Date('2026-01-02T00:00:00Z'); + + // Act + const { container } = render(DateTime, { + formatOptions: { timeZone: 'UTC', timeZoneName: 'short' }, + value + }); + const text = container.textContent?.trim() ?? ''; + + // Assert + expect(text).toContain('Jan 2, 2026'); + expect(text).toContain('12:00:00 AM'); + expect(text).toContain('UTC'); + }); + + it('preserves the local day boundary for a requested timezone', () => { + // Arrange + const value = new Date('2026-01-02T00:04:05Z'); + + // Act + const { container } = render(DateTime, { + formatOptions: { timeZone: 'America/Los_Angeles', timeZoneName: 'short' }, + value + }); + const text = container.textContent?.trim() ?? ''; + + // Assert + expect(text).toContain('Jan 1, 2026'); + expect(text).toContain('4:04:05 PM'); + expect(text).toContain('PST'); + }); + + it('renders the previous calendar day across a timezone boundary', () => { + // Arrange + const value = new Date('2026-01-02T00:04:05Z'); + + // Act + const { container } = render(DateTime, { + formatOptions: { timeZone: 'America/Los_Angeles', timeZoneName: 'short' }, + value + }); + + // Assert + expect(container.textContent).toContain('Jan 1, 2026'); + expect(container.textContent).toContain('4:04:05 PM'); + expect(container.textContent).toContain('PST'); + }); + + it.each([undefined, 'not a timestamp'] as const)('omits missing and invalid value %s', (value) => { + // Arrange + const props = { value }; + + // Act + const { container } = render(DateTime, props); + + // Assert + expect(container.textContent).toBe(''); + }); +}); 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 d4ebf6529a..31ae569d8d 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,6 +1,6 @@ {#if date} - - - - {#snippet child({ props })} - - - - {/snippet} - - {fullTimestamp} - - + + + {#snippet child({ props })} + + {/snippet} + + + {/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 af53d4730b..7f257ce60d 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 @@ -3,7 +3,7 @@ import { tick } from 'svelte'; import Time from 'svelte-time'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import TimeAgo from './time-ago.svelte'; +import TimeAgoTestHarness from './time-ago.test-harness.svelte'; describe('TimeAgo', () => { beforeAll(() => { @@ -26,6 +26,7 @@ describe('TimeAgo', () => { }); it('does not loop when adaptive clocks straddle an age boundary', async () => { + // Arrange vi.useFakeTimers(); const base = new Date('2026-08-11T12:00:00Z'); vi.setSystemTime(base); @@ -37,6 +38,7 @@ describe('TimeAgo', () => { }); await tick(); + // Act vi.setSystemTime(new Date(base.getTime() + 2_000)); render(Time, { live: true, @@ -45,20 +47,24 @@ describe('TimeAgo', () => { }); await tick(); - render(TimeAgo, { + render(TimeAgoTestHarness, { value: new Date(base.getTime() - (60 * 60 * 1_000 - 1_000)) }); await tick(); + // Assert expect(screen.getByText('an hour ago')).toBeTruthy(); }); it('exposes the full timestamp through an accessible tooltip', async () => { + // Arrange const value = new Date('2026-08-11T12:34:56Z'); - const { container } = render(TimeAgo, { value }); + const { container } = render(TimeAgoTestHarness, { value }); const trigger = container.querySelector('[data-slot="tooltip-trigger"]'); expect(trigger).not.toBeNull(); + + // Act await fireEvent.focus(trigger!); const tooltip = await screen.findByRole('tooltip'); @@ -73,17 +79,21 @@ describe('TimeAgo', () => { year: 'numeric' }).format(value); + // Assert expect(tooltip.textContent).toContain(expectedTimestamp); + expect(trigger?.getAttribute('tabindex')).toBe('0'); + expect(trigger?.getAttribute('title')).toBeNull(); }); - 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(); + it.each([undefined, 'not a timestamp'] as const)('omits missing and invalid timestamp %s', (value) => { + // Arrange + const props = { value }; + + // Act + const { container } = render(TimeAgoTestHarness, props); - const invalid = render(TimeAgo, { value: 'not a timestamp' }); - expect(invalid.container.textContent).toBe(''); - expect(invalid.container.querySelector('[data-slot="tooltip-trigger"]')).toBeNull(); + // Assert + expect(container.textContent).toBe(''); + expect(container.querySelector('[data-slot="tooltip-trigger"]')).toBeNull(); }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.test-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.test-harness.svelte new file mode 100644 index 0000000000..ac3ecd1a94 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/time-ago.test-harness.svelte @@ -0,0 +1,15 @@ + + + + + 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 31d9f46663..7cbae81e50 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, formatDateTime, getDifferenceInSeconds, getRelativeTimeFormatUnit, getSetIntervalTime } from './dates'; +import { formatDateLabel, getDifferenceInSeconds, getRelativeTimeFormatUnit, getSetIntervalTime } from './dates'; describe('formatDateLabel', () => { it('preserves local date and relative-label behavior without a timezone override', () => { @@ -53,21 +53,6 @@ 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 71ff580999..c4072edcb9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/dates.ts @@ -131,19 +131,6 @@ 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', From aba8356888e230ccc3ebdb768d693b3e744a034e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 21 Sep 2026 20:26:35 -0500 Subject: [PATCH 3/7] Keep timestamp tooltip focus on session event links --- .../e2e/tests/session-investigation.e2e.ts | 55 ++++++++++++++++++- .../e2e/tests/timestamp-tooltips.e2e.ts | 13 +++++ .../components/views/session-events.svelte | 8 +-- .../components/formatters/time-ago.svelte | 39 +++++++++---- .../formatters/time-ago.svelte.test.ts | 21 ++++++- .../formatters/time-ago.test-harness.svelte | 9 ++- 6 files changed, 125 insertions(+), 20 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts index 09f93c49b7..0e5040eb42 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts @@ -2,7 +2,7 @@ import { createReferenceId, expect, test } from '../fixtures/e2e-test'; import { getVisibleRow, getVisibleText } from '../support/page-helpers'; import { createRepresentativeEvent, createSessionEvent } from '../support/synthetic-event'; -test('operator can find and inspect a user session', async ({ e2eApi, e2eScenario, page, request }) => { +test('operator can find and inspect a user session', async ({ e2eApi, e2eScenario, page, request }, testInfo) => { const sessionId = createReferenceId(e2eScenario.run, '-session'); const eventReferenceId = createReferenceId(e2eScenario.run, '-session-error'); const identity = `session-${e2eScenario.run}@exceptionless.test`; @@ -68,6 +68,20 @@ test('operator can find and inspect a user session', async ({ e2eApi, e2eScenari const eventSheet = page.getByRole('dialog', { name: 'Event' }); await expect(eventSheet).toBeVisible(); await expect(eventSheet.getByText(name).filter({ visible: true }).first()).toBeVisible(); + await eventSheet.getByRole('tab', { name: 'Session Events' }).click(); + const sheetTimestamp = eventSheet.getByRole('link', { exact: true, name: `Open event ${relatedEventId}` }); + await sheetTimestamp.hover(); + const sheetTooltip = page.getByRole('tooltip'); + await expect(sheetTooltip).toBeVisible(); + await expect + .poll(() => + sheetTooltip.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + return element.contains(document.elementFromPoint(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)); + }) + ) + .toBe(true); + await page.keyboard.press('Escape'); await eventSheet.getByRole('link', { name: 'Open details in new window' }).click(); await expect(page).toHaveURL(/\/next\/(?:event|stack\/[^/]+\/event)\//); @@ -98,6 +112,45 @@ test('operator can find and inspect a user session', async ({ e2eApi, e2eScenari expect(eventsUrl.searchParams.get('time')).toBe('all'); }); + await test.step('timestamp link owns keyboard focus, navigation, and a visible tooltip', async () => { + const timestampLink = page.getByRole('link', { exact: true, name: `Open event ${relatedEventId}` }); + await expect(timestampLink.locator('time')).not.toHaveAttribute('tabindex'); + await expect(timestampLink).not.toHaveAttribute('title'); + + // Enter the timestamp from the preceding user link with real keyboard navigation. + const row = timestampLink.locator('xpath=ancestor::tr'); + await row.getByRole('link').nth(1).focus(); + await page.keyboard.press('Tab'); + await expect(timestampLink).toBeFocused(); + const tooltip = page.getByRole('tooltip'); + await expect(tooltip).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(tooltip).toBeHidden(); + await page.keyboard.press('Tab'); + await expect(timestampLink.locator('time')).not.toBeFocused(); + await timestampLink.hover(); + await expect(tooltip).toBeVisible(); + + await testInfo.attach('timestamp-tooltip-desktop', { body: await page.screenshot(), contentType: 'image/png' }); + + // The session table can scroll horizontally; its tooltip must escape clipping. + await page.setViewportSize({ height: 360, width: 640 }); + await timestampLink.hover(); + await expect(tooltip).toBeVisible(); + await expect + .poll(async () => { + const bounds = await tooltip.boundingBox(); + return bounds !== null && bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= 640 && bounds.y + bounds.height <= 360; + }) + .toBe(true); + await testInfo.attach('timestamp-tooltip-small-viewport', { body: await page.screenshot(), contentType: 'image/png' }); + expect(await tooltip.evaluate((element) => element.closest('[data-slot="table-container"]'))).toBeNull(); + await page.setViewportSize({ height: 720, width: 1280 }); + await timestampLink.focus(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(new RegExp(`/event/${relatedEventId}(?:[?#]|$)`)); + }); + await test.step('event detail messages wrap and small alignment fixes render consistently', async () => { await page.goto(`/next/stack/${relatedStackId}/event/${relatedEventId}`); await expect(page.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true'); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/timestamp-tooltips.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/timestamp-tooltips.e2e.ts index 6ecd3b715a..1f40020815 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/timestamp-tooltips.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/timestamp-tooltips.e2e.ts @@ -34,6 +34,19 @@ test('event and stack timestamps expose full time through keyboard navigation an await expect(timestamp).not.toHaveAttribute('title'); await expect(page.getByRole('tooltip')).toContainText('12:00:00 AM UTC'); await expect(page.getByRole('tooltip')).toContainText(String(date.getUTCFullYear())); + await expect + .poll(async () => { + const bounds = await page.getByRole('tooltip').boundingBox(); + const viewport = page.viewportSize()!; + return ( + bounds !== null && + bounds.x >= 0 && + bounds.y >= 0 && + bounds.x + bounds.width <= viewport.width && + bounds.y + bounds.height <= viewport.height + ); + }) + .toBe(true); }); await test.step('Escape dismisses the tooltip and hover reopens it', async () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte index 6a25a93e7e..2d6a75977a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte @@ -169,14 +169,12 @@ - - - + value={sessionEvent.date} + /> {/each} 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 31ae569d8d..05035480ff 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,13 +1,15 @@ - + {#if linked} + + {:else} + + {/if} From 40a8269995a80b68cee7e53d9a52b0c6ac1e63ee Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 21 Sep 2026 20:40:48 -0500 Subject: [PATCH 4/7] Compose timestamp triggers with typography links --- .../components/views/session-events.svelte | 20 ++++++++---- .../components/formatters/time-ago.svelte | 32 +++++++++---------- .../formatters/time-ago.svelte.test.ts | 4 +++ .../formatters/time-ago.test-harness.svelte | 7 +++- 4 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte index 2d6a75977a..d671b805c4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/views/session-events.svelte @@ -2,6 +2,7 @@ import type { PersistentEvent } from '$features/events/models'; import TimeAgo from '$comp/formatters/time-ago.svelte'; + import { A } from '$comp/typography'; import * as Alert from '$comp/ui/alert'; import { Button } from '$comp/ui/button'; import { Skeleton } from '$comp/ui/skeleton'; @@ -169,12 +170,19 @@ - + + {#snippet child({ children, props })} + + {@render children()} + + {/snippet} + {/each} 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 05035480ff..452d3e4526 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,15 +1,17 @@ +{#snippet relativeTime()} +