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 }[]) {