diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx index e5f488d9..1904d78b 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx @@ -130,7 +130,7 @@ export function Client() { - {summary.uptime === null ? "—" : `${summary.uptime.toFixed(2)}%`} + {summary.uptime === null ? "—" : `${summary.uptime.toFixed(3)}%`} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/examples.ts b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/examples.ts index 4ea78aea..9a6212af 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/examples.ts +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/examples.ts @@ -41,7 +41,7 @@ const COMPONENTS: { id: number; name: string; type: "monitor" | "static" }[] = [ { id: 3, name: "Documentation", type: "static" }, ]; -const round = (value: number) => Math.round(value * 100) / 100; +const round = (value: number) => Math.round(value * 1_000) / 1_000; function average(values: (number | null)[]): number | null { const present = values.filter((v): v is number => v !== null); diff --git a/apps/dashboard/src/components/data-table/status-page-history/columns.tsx b/apps/dashboard/src/components/data-table/status-page-history/columns.tsx index 9243f03b..582e2f27 100644 --- a/apps/dashboard/src/components/data-table/status-page-history/columns.tsx +++ b/apps/dashboard/src/components/data-table/status-page-history/columns.tsx @@ -71,7 +71,7 @@ function rollingColumn(window: HistoryWindow): ColumnDef { header: "Total", cell: ({ row }) => { const value = row.original.rolling[windowKey(window)]; - return value === null ? "–" : `${value.toFixed(2)}%`; + return value === null ? "–" : `${value.toFixed(3)}%`; }, enableSorting: false, meta: { diff --git a/apps/dashboard/src/components/data-table/status-page-history/table-cell-uptime.tsx b/apps/dashboard/src/components/data-table/status-page-history/table-cell-uptime.tsx index 80501383..5e2375fa 100644 --- a/apps/dashboard/src/components/data-table/status-page-history/table-cell-uptime.tsx +++ b/apps/dashboard/src/components/data-table/status-page-history/table-cell-uptime.tsx @@ -112,7 +112,7 @@ export function TableCellUptime({ statusStyles[cell.status], )} > - {cell.percentage === null ? "–" : `${cell.percentage.toFixed(2)}%`} + {cell.percentage === null ? "–" : `${cell.percentage.toFixed(3)}%`} Uptime - {cell.percentage.toFixed(2)} + {cell.percentage.toFixed(3)} % diff --git a/apps/dashboard/src/components/metric/global-uptime/section.tsx b/apps/dashboard/src/components/metric/global-uptime/section.tsx index 9c533941..9290ed7d 100644 --- a/apps/dashboard/src/components/metric/global-uptime/section.tsx +++ b/apps/dashboard/src/components/metric/global-uptime/section.tsx @@ -76,7 +76,7 @@ export function GlobalUptimeSection({ }); } if (k === "uptime") { - return formatPercentage(value ?? 0); + return formatPercentage(value ?? 0, 3); } if (k.startsWith("p")) { return formatMilliseconds(value ?? 0); diff --git a/apps/dashboard/src/lib/formatter.ts b/apps/dashboard/src/lib/formatter.ts index f40d4255..a2b4e076 100644 --- a/apps/dashboard/src/lib/formatter.ts +++ b/apps/dashboard/src/lib/formatter.ts @@ -15,12 +15,12 @@ export function formatMilliseconds(ms: number) { }).format(ms)}`; } -export function formatPercentage(value: number) { +export function formatPercentage(value: number, fractionDigits = 2) { if (Number.isNaN(value)) return "100%"; return `${Intl.NumberFormat("en-US", { style: "percent", - minimumFractionDigits: 2, - maximumFractionDigits: 2, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, }).format(value)}`; } diff --git a/apps/status-page/src/components/status-page/utils.ts b/apps/status-page/src/components/status-page/utils.ts index 00e7640a..1cd9ff74 100644 --- a/apps/status-page/src/components/status-page/utils.ts +++ b/apps/status-page/src/components/status-page/utils.ts @@ -115,33 +115,3 @@ export function getHighestStatus(items: VariantType[]) { if (items.some((item) => item === "info")) return "info"; return "success"; } - -export function getTotalUptime(item: ChartData[]) { - const { ok, total } = item.reduce( - (acc, item) => ({ - ok: acc.ok + item.success + item.degraded + item.info, - total: acc.total + item.success + item.degraded + item.info + item.error, - }), - { - ok: 0, - total: 0, - }, - ); - - if (total === 0) return 100; - return Math.round((ok / total) * 10000) / 100; -} - -export function getManualUptime( - items: { from: Date | null; to: Date | null }[], - days: number, -) { - const duration = items.reduce((acc, item) => { - if (!item.from) return acc; - return acc + ((item.to || new Date()).getTime() - item.from.getTime()); - }, 0); - - const total = days * 24 * 60 * 60 * 1000; - - return Math.round(((total - duration) / total) * 10000) / 100; -} diff --git a/apps/status-page/src/content/markdown/generators.test.ts b/apps/status-page/src/content/markdown/generators.test.ts index 0bfd7274..06399bfc 100644 --- a/apps/status-page/src/content/markdown/generators.test.ts +++ b/apps/status-page/src/content/markdown/generators.test.ts @@ -595,7 +595,7 @@ describe("generateMonitor", () => { test("KPI table", () => { expect(md).toContain("| Global latency (p75) | 200ms – 300ms |"); expect(md).toContain("| Region latency | 2 regions · fastest: iad |"); - expect(md).toContain("| Uptime (last 7 days) | 97.50% · 200 checks |"); + expect(md).toContain("| Uptime (last 7 days) | 97.500% · 200 checks |"); }); test("percentile table", () => { diff --git a/apps/status-page/src/content/markdown/helpers.ts b/apps/status-page/src/content/markdown/helpers.ts index ce00d1dd..39759851 100644 --- a/apps/status-page/src/content/markdown/helpers.ts +++ b/apps/status-page/src/content/markdown/helpers.ts @@ -143,7 +143,7 @@ export function formatMs(value: number | null | undefined): string { } export function formatPercent(ratio: number): string { - return `${(ratio * 100).toFixed(2)}%`; + return `${(ratio * 100).toFixed(3)}%`; } /** Build the public-facing canonical (HTML) URL for a path under a page. */ diff --git a/apps/status-page/src/lib/formatter.ts b/apps/status-page/src/lib/formatter.ts index 7a8f7c87..ccad1bfd 100644 --- a/apps/status-page/src/lib/formatter.ts +++ b/apps/status-page/src/lib/formatter.ts @@ -32,12 +32,12 @@ export function formatMillisecondsRange(min: number, max: number) { return `${formatMilliseconds(min)} - ${formatMilliseconds(max)}`; } -export function formatPercentage(value: number) { +export function formatPercentage(value: number, fractionDigits = 3) { if (Number.isNaN(value)) return "100%"; return `${Intl.NumberFormat("en-US", { style: "percent", - minimumFractionDigits: 2, - maximumFractionDigits: 2, + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, }).format(value)}`; } diff --git a/packages/services/src/frozen-uptime/__tests__/get-history.test.ts b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts index 02178906..4a3f938a 100644 --- a/packages/services/src/frozen-uptime/__tests__/get-history.test.ts +++ b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts @@ -287,7 +287,7 @@ describe("getUptimeHistory", () => { const totalMs = monthEnd(key(1)).getTime() - monthStart(key(1)).getTime(); const expected = - Math.floor(((totalMs - twelveHours) / totalMs) * 10_000) / 100; + Math.floor(((totalMs - twelveHours) / totalMs) * 100_000) / 1_000; expect(res.rows[0].months[key(1)]).toBe(expected); }); }); @@ -334,9 +334,9 @@ describe("getUptimeHistory", () => { const totalMs = monthEnd(key(1)).getTime() - monthStart(key(1)).getTime(); const legacyExpected = - Math.floor(((totalMs - twelveHours) / totalMs) * 10_000) / 100; + Math.floor(((totalMs - twelveHours) / totalMs) * 100_000) / 1_000; const partialExpected = - Math.floor(((totalMs - twelveHours / 2) / totalMs) * 10_000) / 100; + Math.floor(((totalMs - twelveHours / 2) / totalMs) * 100_000) / 1_000; const byName = new Map(res.rows.map((r) => [r.component.name, r])); // empty projection falls through to legacy full-duration downtime @@ -430,7 +430,7 @@ describe("getUptimeHistory", () => { }); }); - test("a 0/0/0 month is null, not 0% — and floor rounding never shows 100.00 with a failed check", async () => { + test("a 0/0/0 month is null, not 0% — and floor rounding never shows 100.000 with a failed check", async () => { await withTestTransaction(async (tx) => { const ctx = { ...userCtx, db: tx }; const testMonitor = await insertMonitor(tx); @@ -462,7 +462,7 @@ describe("getUptimeHistory", () => { const row = res.rows[0]; expect(row.months[key(2)]).toBe(null); - expect(row.months[key(1)]).toBe(99.99); + expect(row.months[key(1)]).toBe(99.999); // live month with zero pipe rows is also no-data expect(row.months[key(0)]).toBe(null); }); @@ -566,7 +566,7 @@ describe("getUptimeHistory", () => { const row = res.rows[0]; const totalMs = monthDays(`${key(1)}-01`).length * MS_PER_DAY; const expected = - Math.floor(((totalMs - 3 * 3_600_000) / totalMs) * 10_000) / 100; + Math.floor(((totalMs - 3 * 3_600_000) / totalMs) * 100_000) / 1_000; expect(row.months[key(1)]).toBe(expected); // events overlap key(2) not at all and it has no counts → null anyway expect(row.months[key(2)]).toBe(null); @@ -673,7 +673,8 @@ describe("getUptimeHistory", () => { // denominator = elapsed 36h (not 48h): 2h down → ~94.44, not 95.83 const lastDayEnd = Date.parse(`${key(0)}-02T23:59:59.999Z`); const total = 2 * MS_PER_DAY - (lastDayEnd - injectedNow.getTime()); - const expected = Math.floor(((total - twoHours) / total) * 10_000) / 100; + const expected = + Math.floor(((total - twoHours) / total) * 100_000) / 1_000; expect(res.rows[0].months[key(0)]).toBe(expected); expect(expected).toBeLessThan(95); }); @@ -758,7 +759,7 @@ describe("getUptimeHistory", () => { expect(row.months[key(2)]).toBe(null); const totalMs = monthEnd(key(1)).getTime() - monthStart(key(1)).getTime(); const expected = - Math.floor(((totalMs - sixHours) / totalMs) * 10_000) / 100; + Math.floor(((totalMs - sixHours) / totalMs) * 100_000) / 1_000; expect(row.months[key(1)]).toBe(expected); // current month: no events → clean so far expect(row.months[key(0)]).toBe(100); diff --git a/packages/services/src/frozen-uptime/get-history.ts b/packages/services/src/frozen-uptime/get-history.ts index 7411edf2..bb4eea2e 100644 --- a/packages/services/src/frozen-uptime/get-history.ts +++ b/packages/services/src/frozen-uptime/get-history.ts @@ -385,9 +385,7 @@ export async function getUptimeHistory(args: { summary[wk] = { uptime: uptimes.length > 0 - ? Math.floor( - (uptimes.reduce((a, b) => a + b, 0) / uptimes.length) * 100, - ) / 100 + ? floorPct(uptimes.reduce((a, b) => a + b, 0) / uptimes.length / 100) : null, reports: seen.size, }; diff --git a/packages/services/src/status-timeline/__tests__/uptime.test.ts b/packages/services/src/status-timeline/__tests__/uptime.test.ts index 3f4c9d94..81f9cfeb 100644 --- a/packages/services/src/status-timeline/__tests__/uptime.test.ts +++ b/packages/services/src/status-timeline/__tests__/uptime.test.ts @@ -59,11 +59,25 @@ function legacyReport(fromMs: number, toMs: number): Event { } describe("floorPct", () => { - test("floors instead of rounding — one failed check never shows 100.00", () => { - expect(floorPct(99_999 / 100_000)).toBe(99.99); + test("floors instead of rounding — one failed check never shows 100.000", () => { + expect(floorPct(99_999 / 100_000)).toBe(99.999); + expect(floorPct(999_999 / 1_000_000)).toBe(99.999); expect(floorPct(1)).toBe(100); expect(floorPct(0)).toBe(0); }); + + test("exact thousandths survive float error in the caller's division", () => { + expect(floorPct(29 / 100)).toBe(29); + expect(floorPct(57 / 100)).toBe(57); + expect(floorPct(23 / 40)).toBe(57.5); + expect(floorPct(29 / 50)).toBe(58); + expect(floorPct(23 / 80)).toBe(28.75); + }); + + test("the epsilon does not lift a value onto the next thousandth", () => { + expect(floorPct(28_9995 / 1_000_000)).toBe(28.999); + expect(floorPct(99_9999 / 1_000_000)).toBe(99.999); + }); }); describe("requestsTally", () => { diff --git a/packages/services/src/status-timeline/uptime.ts b/packages/services/src/status-timeline/uptime.ts index 5666a08e..9e62d872 100644 --- a/packages/services/src/status-timeline/uptime.ts +++ b/packages/services/src/status-timeline/uptime.ts @@ -55,9 +55,12 @@ export function clipToCoverage( ); } -// floor so a single failed check never rounds up to 100.00 +// floor so a single failed check never rounds up to 100.000. the epsilon +// absorbs float error from the caller's a/b division (0.29 * 100_000 is +// 28999.999999999996) — it is ~450x the ULP at this scale, far too small to +// lift a genuinely-below value onto the next thousandth. export function floorPct(ratio: number): number { - return Math.floor(ratio * 10_000) / 100; + return Math.floor(ratio * 100_000 + 1e-8) / 1_000; } export function requestsTally(counts: CheckCounts[]): { diff --git a/packages/tracker/src/tracker.test.ts b/packages/tracker/src/tracker.test.ts index 8f42f53f..34fb595b 100644 --- a/packages/tracker/src/tracker.test.ts +++ b/packages/tracker/src/tracker.test.ts @@ -109,9 +109,9 @@ describe("Tracker", () => { expect(tracker.totalUptime).toBe(75); }); - test("rounds to two decimal places", () => { + test("rounds to three decimal places", () => { const tracker = new Tracker({ data: [day("2024-01-01", 3, 2)] }); - expect(tracker.totalUptime).toBe(66.67); + expect(tracker.totalUptime).toBe(66.667); }); test("aggregates across multiple days", () => { diff --git a/packages/tracker/src/tracker.ts b/packages/tracker/src/tracker.ts index f5da4d4c..36fbd230 100644 --- a/packages/tracker/src/tracker.ts +++ b/packages/tracker/src/tracker.ts @@ -49,7 +49,7 @@ export class Tracker { private calculateUptime(data: { ok: number; count: number }[]) { const { count, ok } = this.aggregatedData(data); if (count === 0) return 100; // starting with 100% uptime - return Math.round((ok / count) * 10_000) / 100; // round to 2 decimal places + return Math.round((ok / count) * 100_000) / 1_000; // round to 3 decimal places } private aggregatedData(data: { ok: number; count: number }[]) {