From f6bef74fe73b6e457d5a5a8cef221fd8e460ccf3 Mon Sep 17 00:00:00 2001 From: Takaaki Abe Date: Thu, 15 Oct 2026 20:06:55 +0900 Subject: [PATCH] fix(status-page): show the report duration only on the resolved update (#2818) * fix(status-page): show the report duration only on the resolved update Each update carried a duration measured from a different point, and placed after the timestamp "(24 hours earlier)" reads as "24 hours ago". Keep one: the resolved update's time since the first update. * fix(status-page): skip the resolved duration when it rounds to 0 seconds --- apps/status-page/messages/de.json | 2 - apps/status-page/messages/en.json | 2 - apps/status-page/messages/fr.json | 2 - apps/status-page/messages/hi.json | 2 - apps/status-page/messages/ja.json | 2 - apps/status-page/messages/ko.json | 2 - apps/status-page/messages/tr.json | 2 - .../i18n/status-blocks-provider.tsx | 3 - .../src/lib/status-event-timeline.test.ts | 74 +++++++++++++++++++ packages/ui/src/components/blocks/README.md | 2 - .../src/components/blocks/status-events.tsx | 61 ++++++--------- .../ui/src/components/blocks/status-i18n.tsx | 2 - .../ui/src/components/blocks/status.utils.ts | 2 - 13 files changed, 97 insertions(+), 61 deletions(-) create mode 100644 apps/status-page/src/lib/status-event-timeline.test.ts diff --git a/apps/status-page/messages/de.json b/apps/status-page/messages/de.json index 033e120df..37507b1e5 100644 --- a/apps/status-page/messages/de.json +++ b/apps/status-page/messages/de.json @@ -121,8 +121,6 @@ "apbxET": "Tag {n} Status", "GbVCQb": "Erneut klicken zum Lösen", "Coy6SH": "Kalender", - "heezSZ": "(in {duration})", - "lKJjwW": "({timeFromLast} früher)", "VaZnIX": "(für {duration})", "jC7BY1": "über {duration}", "tzMNF3": "Status", diff --git a/apps/status-page/messages/en.json b/apps/status-page/messages/en.json index 42c811a63..5acf039fc 100644 --- a/apps/status-page/messages/en.json +++ b/apps/status-page/messages/en.json @@ -121,8 +121,6 @@ "apbxET": "Day {n} status", "GbVCQb": "Click again to unpin", "Coy6SH": "Calendar", - "heezSZ": "(in {duration})", - "lKJjwW": "({timeFromLast} earlier)", "VaZnIX": "(for {duration})", "jC7BY1": "across {duration}", "tzMNF3": "Status", diff --git a/apps/status-page/messages/fr.json b/apps/status-page/messages/fr.json index c47e72176..91ca7cb28 100644 --- a/apps/status-page/messages/fr.json +++ b/apps/status-page/messages/fr.json @@ -121,8 +121,6 @@ "apbxET": "Statut du jour {n}", "GbVCQb": "Cliquez à nouveau pour désépingler", "Coy6SH": "Calendrier", - "heezSZ": "(en {duration})", - "lKJjwW": "({timeFromLast} plus tôt)", "VaZnIX": "(pendant {duration})", "jC7BY1": "sur {duration}", "tzMNF3": "Statut", diff --git a/apps/status-page/messages/hi.json b/apps/status-page/messages/hi.json index d11668716..b8fe9a175 100644 --- a/apps/status-page/messages/hi.json +++ b/apps/status-page/messages/hi.json @@ -121,8 +121,6 @@ "apbxET": "दिन {n} की स्थिति", "GbVCQb": "अनपिन करने के लिए फिर से क्लिक करें", "Coy6SH": "कैलेंडर", - "heezSZ": "({duration} में)", - "lKJjwW": "({timeFromLast} पहले)", "VaZnIX": "({duration} के लिए)", "jC7BY1": "{duration} भर में", "tzMNF3": "स्थिति", diff --git a/apps/status-page/messages/ja.json b/apps/status-page/messages/ja.json index cfee33b1f..68747d65d 100644 --- a/apps/status-page/messages/ja.json +++ b/apps/status-page/messages/ja.json @@ -121,8 +121,6 @@ "apbxET": "{n}日目のステータス", "GbVCQb": "もう一度クリックでピン留めを解除", "Coy6SH": "カレンダー", - "heezSZ": "(発生から{duration})", - "lKJjwW": "({timeFromLast}前)", "VaZnIX": "(計{duration})", "jC7BY1": "{duration}にわたって", "tzMNF3": "ステータス", diff --git a/apps/status-page/messages/ko.json b/apps/status-page/messages/ko.json index 060fb7760..718499a62 100644 --- a/apps/status-page/messages/ko.json +++ b/apps/status-page/messages/ko.json @@ -121,8 +121,6 @@ "apbxET": "{n}일차 상태", "GbVCQb": "다시 클릭하여 고정 해제", "Coy6SH": "캘린더", - "heezSZ": "({duration} 후)", - "lKJjwW": "({timeFromLast} 전)", "VaZnIX": "({duration} 동안)", "jC7BY1": "{duration} 동안", "tzMNF3": "상태", diff --git a/apps/status-page/messages/tr.json b/apps/status-page/messages/tr.json index 452ccbcb7..70c1a056b 100644 --- a/apps/status-page/messages/tr.json +++ b/apps/status-page/messages/tr.json @@ -121,8 +121,6 @@ "apbxET": "Gün {n} durumu", "GbVCQb": "Sabitlemeyi kaldırmak için tekrar tıklayın", "Coy6SH": "Takvim", - "heezSZ": "({duration} içinde)", - "lKJjwW": "({timeFromLast} önce)", "VaZnIX": "({duration} boyunca)", "jC7BY1": "{duration} boyunca", "tzMNF3": "Durum", diff --git a/apps/status-page/src/components/i18n/status-blocks-provider.tsx b/apps/status-page/src/components/i18n/status-blocks-provider.tsx index afa5e7b85..4d8573c42 100644 --- a/apps/status-page/src/components/i18n/status-blocks-provider.tsx +++ b/apps/status-page/src/components/i18n/status-blocks-provider.tsx @@ -109,9 +109,6 @@ export function StatusBlocksProvider({ calendarTitle: t("Calendar"), - durationIn: (duration: string) => t("(in {duration})", { duration }), - durationEarlier: (timeFromLast: string) => - t("({timeFromLast} earlier)", { timeFromLast }), durationFor: (duration: string) => t("(for {duration})", { duration }), durationAcross: (duration: string) => t("across {duration}", { duration }), diff --git a/apps/status-page/src/lib/status-event-timeline.test.ts b/apps/status-page/src/lib/status-event-timeline.test.ts new file mode 100644 index 000000000..22e79667a --- /dev/null +++ b/apps/status-page/src/lib/status-event-timeline.test.ts @@ -0,0 +1,74 @@ +import { StatusEventTimelineReport } from "@openstatus/ui/components/blocks/status-events"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +describe("StatusEventTimelineReport", () => { + const updates = [ + { + status: "resolved" as const, + message: "Resolved", + date: new Date("2026-09-03T09:55:00Z"), + }, + { + status: "monitoring" as const, + message: "Monitoring", + date: new Date("2026-09-02T10:00:00Z"), + }, + { + status: "investigating" as const, + message: "Investigating", + date: new Date("2026-08-31T05:00:00Z"), + }, + ]; + + test("shows each update's absolute timestamp", () => { + const html = renderToStaticMarkup( + createElement(StatusEventTimelineReport, { updates }), + ); + expect(html).toContain("September 3 at 9:55 AM (UTC)"); + expect(html).toContain("September 2 at 10:00 AM (UTC)"); + expect(html).toContain("August 31 at 5:00 AM (UTC)"); + }); + + test("shows the time since the first update on the resolved update only", () => { + const html = renderToStaticMarkup( + createElement(StatusEventTimelineReport, { updates }), + ); + expect(html.split("(3 days)").length - 1).toBe(1); + expect(html).not.toContain("(2 days)"); + expect(html).not.toContain("earlier"); + }); + + test("shows no duration while the report is unresolved", () => { + const html = renderToStaticMarkup( + createElement(StatusEventTimelineReport, { updates: updates.slice(1) }), + ); + expect(html).not.toContain("(2 days)"); + }); + + test("shows no duration when resolved at the first update", () => { + const html = renderToStaticMarkup( + createElement(StatusEventTimelineReport, { + updates: [{ ...updates[0], date: updates[2].date }, updates[2]], + }), + ); + expect(html).not.toMatch(/\(\d+ (seconds|minutes|hours|days)/); + }); + + test("shows no duration when resolved less than half a second after the first update", () => { + const html = renderToStaticMarkup( + createElement(StatusEventTimelineReport, { + updates: [ + { + ...updates[0], + date: new Date(updates[2].date.getTime() + 300), + }, + updates[2], + ], + }), + ); + expect(html).not.toMatch(/\(\d+ (seconds|minutes|hours|days)/); + }); +}); diff --git a/packages/ui/src/components/blocks/README.md b/packages/ui/src/components/blocks/README.md index 0716adcf4..5d4211184 100644 --- a/packages/ui/src/components/blocks/README.md +++ b/packages/ui/src/components/blocks/README.md @@ -512,8 +512,6 @@ export function MyStatusBlocksProvider({ children }: { children: React.ReactNode ariaStatusTracker: t("Status tracker"), ariaDayStatus: (n) => t("Day {n} status", { n }), clickAgainToUnpin: t("Click again to unpin"), - durationIn: (s) => t("(in {duration})", { duration: s }), - durationEarlier: (s) => t("({timeFromLast} earlier)", { timeFromLast: s }), durationFor: (s) => t("(for {duration})", { duration: s }), durationAcross: (s) => t("across {duration}", { duration: s }), formatDate: (d) => dateFmt.format(d), diff --git a/packages/ui/src/components/blocks/status-events.tsx b/packages/ui/src/components/blocks/status-events.tsx index a7c1d8494..6e6271370 100644 --- a/packages/ui/src/components/blocks/status-events.tsx +++ b/packages/ui/src/components/blocks/status-events.tsx @@ -438,11 +438,8 @@ export function StatusEventTimelineImpact({ * * Displays a chronological timeline of incident updates, sorted from newest to * oldest. Each update shows the status (investigating → identified → monitoring → resolved), - * timestamp, message, and time elapsed between updates. - * - * **Automatic Duration Calculation**: - * - First update (most recent): Shows total time from start to resolution (if resolved) - * - Other updates: Shows time elapsed since the previous update + * absolute timestamp and message. A resolved report also shows the time from + * its first update to resolution on the latest update. * * @param updates - Array of report updates to display * @param withDot - Whether to show colored status dots (default: true) @@ -474,7 +471,7 @@ export function StatusEventTimelineImpact({ * } * ]} * /> - * // Displays timeline with: "Resolved (in 1 hour)" → "Monitoring (15 minutes earlier)" → etc. + * // Displays timeline with: "Resolved · January 15 at 12:00 PM (UTC) (1 hour)" → "Monitoring · …" → etc. * ``` * * @see StatusEventTimelineReportUpdate - For individual update rendering @@ -492,13 +489,20 @@ export function StatusEventTimelineReport({ maxUpdates?: number; renderMessage?: (message: string) => React.ReactNode; }) { - const labels = useStatusBlocksLabels(); const sortedUpdates = [...updates].sort( (a, b) => b.date.getTime() - a.date.getTime(), ); const displayedUpdates = maxUpdates ? sortedUpdates.slice(0, maxUpdates) : sortedUpdates; + const latest = sortedUpdates[0]; + const firstUpdate = sortedUpdates[sortedUpdates.length - 1]; + const distance = + sortedUpdates.length > 1 && latest.status === "resolved" + ? formatDistanceStrict(firstUpdate.date, latest.date) + : undefined; + const resolvedDuration = + distance && distance !== "0 seconds" ? `(${distance})` : undefined; return (
{/* NOTE: make sure they are sorted by date */} - {displayedUpdates.map((update, index) => { - const updateDate = new Date(update.date); - let durationText: string | undefined; - - if (index === 0) { - const startedAt = new Date( - sortedUpdates[sortedUpdates.length - 1].date, - ); - const duration = formatDistanceStrict(startedAt, updateDate); - - if (duration !== "0 seconds" && update.status === "resolved") { - durationText = labels.durationIn(duration); - } - } else { - const lastUpdateDate = new Date(displayedUpdates[index - 1].date); - const timeFromLast = formatDistanceStrict(updateDate, lastUpdateDate); - durationText = labels.durationEarlier(timeFromLast); - } - - return ( - - ); - })} + {/* NOTE: no duration on the other updates: without a shared anchor it reads as "x ago". */} + {displayedUpdates.map((update, index) => ( + + ))}
); } diff --git a/packages/ui/src/components/blocks/status-i18n.tsx b/packages/ui/src/components/blocks/status-i18n.tsx index 0610dcbb0..ffb213d5e 100644 --- a/packages/ui/src/components/blocks/status-i18n.tsx +++ b/packages/ui/src/components/blocks/status-i18n.tsx @@ -55,8 +55,6 @@ export type StatusBlocksLabels = { /** Heading shown on the status-calendar block. */ calendarTitle: string; - durationIn: (s: string) => string; - durationEarlier: (s: string) => string; durationFor: (s: string) => string; durationAcross: (s: string) => string; diff --git a/packages/ui/src/components/blocks/status.utils.ts b/packages/ui/src/components/blocks/status.utils.ts index ee7839e20..86e95b845 100644 --- a/packages/ui/src/components/blocks/status.utils.ts +++ b/packages/ui/src/components/blocks/status.utils.ts @@ -341,8 +341,6 @@ export const defaultStatusBlocksLabels = { calendarTitle: "Calendar", - durationIn: (s: string) => `(in ${s})`, - durationEarlier: (s: string) => `(${s} earlier)`, durationFor: (s: string) => `(for ${s})`, durationAcross: (s: string) => `across ${s}`, -- 2.51.2