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
89 changes: 89 additions & 0 deletions src/Frontend/src/components/AdaptiveTimestamp.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
import { cleanup, render, screen } from "@testing-library/vue";
import AdaptiveTimestamp from "@/components/AdaptiveTimestamp.vue";
import { useTimestampZone } from "@/composables/timestampZone";

const NOW = new Date("2026-09-03T15:00:00");

function renderAt(date: Date) {
cleanup();
render(AdaptiveTimestamp, { props: { dateUtc: date.toISOString() } });
return {
absolute: screen.getByTestId("adaptive-absolute").textContent!,
relative: screen.getByTestId("adaptive-relative").textContent!,
};
}

describe("FEATURE: Age-adaptive timestamps", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});

afterEach(() => {
vi.useRealTimers();
useTimestampZone().zone.value = "local";
});

test("EXAMPLE: Today shows the time only", () => {
const date = new Date("2026-09-03T13:00:00");
const { absolute, relative } = renderAt(date);

expect(absolute).toBe(date.toLocaleTimeString());
expect(relative).toContain("2 hours ago");
});

test("EXAMPLE: Yesterday is labeled yesterday", () => {
const date = new Date("2026-09-02T13:00:00");
const { absolute, relative } = renderAt(date);

expect(absolute).toBe(`yesterday ${date.toLocaleTimeString()}`);
expect(relative).toContain("1 day ago");
});

test("EXAMPLE: Within the past week the weekday is named", () => {
const date = new Date("2026-08-31T13:00:00"); // Monday
const { absolute, relative } = renderAt(date);

expect(absolute).toBe(`${date.toLocaleDateString(undefined, { weekday: "long" })} ${date.toLocaleTimeString()}`);
expect(relative).toContain("3 days ago");
});

test("EXAMPLE: Older dates keep the regular browser format", () => {
const date = new Date("2026-08-20T13:00:00");
const { absolute, relative } = renderAt(date);

expect(absolute).toBe(date.toLocaleString());
expect(relative).toContain("2 weeks ago");
});

test("EXAMPLE: The part prop renders only one half, for split layouts", () => {
cleanup();
render(AdaptiveTimestamp, { props: { dateUtc: new Date("2026-09-03T13:00:00").toISOString(), part: "relative" } });
expect(screen.queryByTestId("adaptive-absolute")).not.toBeInTheDocument();
expect(screen.getByTestId("adaptive-relative").textContent).toBe("2 hours ago");

cleanup();
render(AdaptiveTimestamp, { props: { dateUtc: new Date("2026-09-03T13:00:00").toISOString(), part: "absolute" } });
expect(screen.queryByTestId("adaptive-relative")).not.toBeInTheDocument();
expect(screen.getByTestId("adaptive-absolute")).toBeInTheDocument();
});

test("EXAMPLE: In UTC mode the wall-clock time is UTC", () => {
const { zone } = useTimestampZone();
zone.value = "utc";

const date = new Date("2026-09-03T13:00:00");
const utcWall = new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds());

// toContain: near the UTC midnight boundary the day prefix may differ from local mode
expect(renderAt(date).absolute).toContain(utcWall.toLocaleTimeString());
});

test("EXAMPLE: The coarse relative label covers moments to years", () => {
expect(renderAt(new Date("2026-09-03T14:59:50")).relative).toContain("moments ago");
expect(renderAt(new Date("2026-09-03T14:30:00")).relative).toContain("30 minutes ago");
expect(renderAt(new Date("2026-06-03T15:00:00")).relative).toContain("3 months ago");
expect(renderAt(new Date("2024-09-03T15:00:00")).relative).toContain("2 years ago");
});
});
37 changes: 37 additions & 0 deletions src/Frontend/src/components/AdaptiveTimestamp.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useDateFormatter } from "@/composables/dateFormatter";
import { useTimestampZone } from "@/composables/timestampZone";

// part: render only one half — lets a layout place the absolute time and the
// age in separate (grid) cells while both keep the dual-zone tooltip
const props = defineProps<{ dateUtc: string; part?: "absolute" | "relative" }>();

const { formatAdaptiveDate, formatCoarseRelative, formatDateTooltip } = useDateFormatter();
const { zone } = useTimestampZone();

// tick so the relative label stays honest and the adaptive form rolls over at midnight
const now = ref(new Date());
let timer: number | undefined;
onMounted(() => {
timer = window.setInterval(() => (now.value = new Date()), 5000);
});
onBeforeUnmount(() => window.clearInterval(timer));

const absolute = computed(() => formatAdaptiveDate(props.dateUtc, () => now.value, zone.value));
const relative = computed(() => formatCoarseRelative(props.dateUtc, () => now.value));
const tooltip = computed(() => formatDateTooltip(props.dateUtc));
</script>

<template>
<span class="adaptive-timestamp" :title="tooltip">
<span v-if="props.part !== 'relative'" data-testid="adaptive-absolute">{{ absolute }}</span>
<span v-if="props.part !== 'absolute'" class="relative" data-testid="adaptive-relative">{{ props.part === "relative" ? "" : " · " }}{{ relative }}</span>
</span>
</template>

<style scoped>
.relative {
color: #999;
}
</style>
9 changes: 9 additions & 0 deletions src/Frontend/src/components/ResultsCount.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ describe("FEATURE: Results count", () => {
expect(screen.getByText("Showing 10 of 10 result(s) · took 320 ms")).toBeInTheDocument();
});

test("EXAMPLE: When the query ran is shown relatively with the timestamp as tooltip", () => {
const completedAt = new Date(Date.now() - 3 * 60 * 1000).toISOString();
render(ResultsCount, { props: { displayed: 10, total: 10, durationMs: 320, completedAt } });

const ran = screen.getByTestId("ran-ago");
expect(ran.textContent).toContain("minutes ago");
expect(ran.getAttribute("title")).toContain("(UTC)");
});

test("EXAMPLE: Zero results render plainly", () => {
render(ResultsCount, { props: { displayed: 0, total: 0 } });

Expand Down
20 changes: 18 additions & 2 deletions src/Frontend/src/components/ResultsCount.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useDateFormatter } from "@/composables/dateFormatter";

const props = defineProps<{
displayed: number;
total: number;
durationMs?: number | null;
completedAt?: string | null;
}>();

const { formatCoarseRelative, formatDateTooltip } = useDateFormatter();
const now = ref(new Date());
let timer: number | undefined;
onMounted(() => {
timer = window.setInterval(() => (now.value = new Date()), 5000);
});
onBeforeUnmount(() => window.clearInterval(timer));

const ranAgo = computed(() => (props.completedAt ? formatCoarseRelative(props.completedAt, () => now.value) : null));
const ranTooltip = computed(() => (props.completedAt ? formatDateTooltip(props.completedAt) : ""));

// Large audit stores easily reach nine digits; format both numbers in the user's locale
const numberFormat = new Intl.NumberFormat();
const formattedDisplayed = computed(() => numberFormat.format(props.displayed));
Expand All @@ -21,7 +34,10 @@ const formattedDuration = computed(() => {
<template>
<div class="col format-showing-results">
<div>
Showing {{ formattedDisplayed }} of {{ formattedTotal }} result(s)<template v-if="formattedDuration"> · took {{ formattedDuration }}</template>
Showing {{ formattedDisplayed }} of {{ formattedTotal }} result(s)<template v-if="formattedDuration"> · took {{ formattedDuration }}</template
><template v-if="ranAgo">
· ran <span :title="ranTooltip" data-testid="ran-ago">{{ ranAgo }}</span></template
>
</div>
</div>
</template>
Expand Down
29 changes: 27 additions & 2 deletions src/Frontend/src/components/audit/AuditList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { useConfigurationStore } from "@/stores/ConfigurationStore";
import { loadDefaultRange, narrowingPresets, resolveTimeRange, type RangePreset } from "@/components/audit/timeRange";

const store = useAuditStore();
const { messages, totalCount, sortBy, messageFilterString, selectedEndpointName, itemsPerPage, timeRangeFrom, timeRangeTo, queryFailed, queryDurationMs } = storeToRefs(store);
const { messages, totalCount, sortBy, messageFilterString, selectedEndpointName, itemsPerPage, timeRangeFrom, timeRangeTo, queryFailed, queryDurationMs, queryCompletedAt } = storeToRefs(store);
const route = useRoute();
const router = useRouter();
const autoRefreshValue = ref<number | null>(null);
Expand Down Expand Up @@ -202,7 +202,7 @@ watch(autoRefreshValue, (newValue) => {
</div>
<div class="row results-row">
<div class="results-summary">
<ResultsCount :displayed="messages.length" :total="totalCount" :duration-ms="queryDurationMs" />
<ResultsCount :displayed="messages.length" :total="totalCount" :duration-ms="queryDurationMs" :completed-at="queryCompletedAt" />
<span v-if="slowQuery && queryInProgress" class="slow-query" role="status" data-testid="slow-query-hint">Still running · a narrower time range makes the query lighter.</span>
</div>
<ResultsOptions />
Expand Down Expand Up @@ -313,5 +313,30 @@ watch(autoRefreshValue, (newValue) => {
margin-bottom: 5rem;
background-color: #ffffff;
position: relative;
/* The results list is a grid that places nothing itself: it only declares the six
columns, and every row (AuditListItem) joins them with `subgrid`. Declaring them here,
once, is what keeps rows aligned: a column is measured across ALL rows, which a row
laying out its own columns cannot do.

1.8em status icon
minmax(0, 1fr) message id: the one value allowed to shrink and break,
so a long id never pushes the values off the row
minmax(max-content, 1fr) each value column, read as: never narrower than its
widest value in the list (so nothing wraps), and once
every column has that, share the leftover width equally
(so the columns spread out on a wide screen instead of
huddling on the left)

Deliberately NOT a size container: combining container-type with content-sized
tracks froze Chrome's layout. */
display: grid;
grid-template-columns: 1.8em minmax(0, 1fr) repeat(4, minmax(max-content, 1fr));
column-gap: 0.375rem;
align-content: start;
}

/* Non-row children (the first-load spinner) span the full width */
.results-table > :not(.item) {
grid-column: 1 / -1;
}
</style>
61 changes: 61 additions & 0 deletions src/Frontend/src/components/audit/AuditListItem.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/vue";
import { createMemoryHistory, createRouter } from "vue-router";
import AuditListItem from "@/components/audit/AuditListItem.vue";
import { type default as Message, MessageStatus } from "@/resources/Message";

const NOW = new Date("2026-09-03T15:00:00");

function renderRow(timeSent: string) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: "/:pathMatch(.*)*", component: { template: "<div />" } }] });
const message = {
id: "id-1",
message_id: "msg-1",
message_type: "Sales.OrderPlaced",
time_sent: timeSent,
status: MessageStatus.Successful,
processing_time: "00:00:00.012",
critical_time: "00:00:00.123",
delivery_time: "00:00:00.001",
} as unknown as Message;
render(AuditListItem, { props: { message }, global: { plugins: [router] } });
}

describe("FEATURE: Audit row layout", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});

afterEach(() => {
vi.useRealTimers();
});

describe("RULE: The age belongs to Time Sent, not to a column of its own", () => {
test("EXAMPLE: The Time Sent cell carries the label, the absolute time and the age", () => {
renderRow(new Date("2026-09-03T14:56:00").toISOString());

const cell = document.querySelector(".time-sent")!;
expect(cell.textContent).toContain("Time Sent:");
expect(cell.querySelector('[data-testid="adaptive-absolute"]')).not.toBeNull();
expect(cell.querySelector('[data-testid="adaptive-relative"]')!.textContent).toContain("4 minutes ago");
expect(screen.getByText("4 minutes ago", { exact: false })).toBeInTheDocument();
});

test("EXAMPLE: The age hides below Bootstrap's lg breakpoint through its display utilities", () => {
renderRow(new Date("2026-09-03T14:56:00").toISOString());

const age = document.querySelector('[data-testid="adaptive-relative"]')!.closest(".d-none.d-lg-inline");
expect(age).not.toBeNull();
// the separator hides together with the age
expect(age!.textContent).toContain("·");
});

test("EXAMPLE: There is no separate age cell", () => {
renderRow(new Date("2026-09-03T14:56:00").toISOString());

// the age is text inside the Time Sent cell, not a grid cell of the row
expect(document.querySelector(".item > .age")).toBeNull();
});
});
});
46 changes: 41 additions & 5 deletions src/Frontend/src/components/audit/AuditListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { computed } from "vue";
import { formatDotNetTimespan } from "@/composables/formatUtils";
import { useRouter, RouterLink } from "vue-router";
import MessageStatusIcon from "@/components/audit/MessageStatusIcon.vue";
import AdaptiveTimestamp from "@/components/AdaptiveTimestamp.vue";

const router = useRouter();

Expand Down Expand Up @@ -33,27 +34,43 @@ const link = computed(() => {
</div>
<div class="message-id">{{ props.message.message_id }}</div>
<div class="message-type">{{ props.message.message_type }}</div>
<div class="time-sent"><span class="label-name">Time Sent:</span>{{ new Date(props.message.time_sent).toLocaleString() }}</div>
<div class="time-sent">
<span class="label-name">Time Sent:</span><AdaptiveTimestamp :date-utc="props.message.time_sent" part="absolute" /><span class="age d-none d-lg-inline"> · <AdaptiveTimestamp :date-utc="props.message.time_sent" part="relative" /></span>
</div>
<div class="critical-time"><span class="label-name">Critical Time:</span>{{ formatDotNetTimespan(props.message.critical_time) }}</div>
<div class="processing-time"><span class="label-name">Processing Time:</span>{{ formatDotNetTimespan(props.message.processing_time) }}</div>
<div class="delivery-time"><span class="label-name">Delivery Time:</span>{{ formatDotNetTimespan(props.message.delivery_time) }}</div>
</RouterLink>
</template>

<style scoped>
/* A row is a two-line card that is also a link.
*
* Its columns are NOT its own. The list (AuditList.vue) declares the six columns once and
* every row joins them with `subgrid`, so a value in one row sits exactly under the same
* value in the next: the widest "Time Sent" in the list sets that column for all rows.
* A row that sized its own columns could only measure its own values, and rows would
* drift apart (or wrap) as soon as one of them held a longer value.
*
* grid-column: 1 / -1 span all six columns of the list
* grid-template-columns: subgrid use them as this row's columns
* grid-template-areas where each cell goes, by name: the message type owns
* the top line, the id and the values share the bottom
* line, the status icon spans both */
.item {
color: inherit;
text-decoration: none;
padding: 0.3rem 0.2rem;
border: 1px solid #ffffff;
border-bottom: 1px solid #eee;
display: grid;
grid-template-columns: 1.8em 1fr 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
grid-column: 1 / -1;
grid-template-columns: subgrid;
grid-template-rows: auto auto;
gap: 0.375rem;
grid-template-areas:
"status message-type message-type message-type time-sent"
"status message-id processing-time critical-time delivery-time";
"status message-type message-type message-type message-type message-type"
"status message-id processing-time critical-time delivery-time time-sent";
}
.item:not(:first-child) {
border-top-color: #eee;
Expand All @@ -75,6 +92,25 @@ const link = computed(() => {
.time-sent {
grid-area: time-sent;
}

/* The age reads as a footnote to the timestamp it follows. It is the first thing to go
when width gets tight: hidden below Bootstrap's lg breakpoint (d-none d-lg-inline on
the span), the absolute timestamp carries the information */
.age,
.age :deep(.relative) {
color: #777f7f;
}

/* All data cells share the bottom line; when one wraps taller, the rest stay
bottom aligned with it */
.message-id,
.processing-time,
.critical-time,
.delivery-time,
.time-sent {
align-self: end;
}

.message-type {
grid-area: message-type;
font-weight: bold;
Expand Down
Loading