diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 240e94b..63bdfc7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,5 +36,8 @@ jobs:
- name: Build status page
run: npm run build:pages
- - name: Run tests
+ - name: Run worker tests
run: npm run test
+
+ - name: Run status-page tests
+ run: npm run test:pages
diff --git a/README.md b/README.md
index 93d29d2..1fa9e55 100644
--- a/README.md
+++ b/README.md
@@ -381,15 +381,18 @@ curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"
### Testing
```bash
-# Fist build the status page
+# First build the status page
npm run build:pages
# Worker tests
npm run test
+# Status page unit tests
+npm run test:pages
+
# Type checking and linting
-npm run check # worker
-npm run check:pages # pages (astro check + tsc)
+npm run check # worker (typecheck + lint + format)
+npm run check:pages # pages (astro check + tsc + lint)
```
## TODO
diff --git a/package.json b/package.json
index bf070e8..3b7c6b2 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"check:pages": "npm run typecheck --workspace=status-page && npm run lint:pages && npm run format:pages:check",
"lint:pages": "npm run lint --workspace=status-page",
"lint:pages:fix": "npm run lint:fix --workspace=status-page",
+ "test:pages": "npm run test --workspace=status-page",
"format:pages": "prettier --write \"src/**/*.ts\" \"status-page/src/**/*.ts\"",
"format:pages:check": "prettier --check \"src/**/*.ts\" \"status-page/src/**/*.ts\""
},
diff --git a/status-page/src/components/Header.astro b/status-page/src/components/Header.astro
index f193bd3..18a2195 100644
--- a/status-page/src/components/Header.astro
+++ b/status-page/src/components/Header.astro
@@ -13,20 +13,9 @@ interface Props {
};
}
+import { formatAbsoluteTime } from '../lib/header.js';
+
const { summary, lastUpdated, title, banner } = Astro.props;
-
-function formatAbsoluteTime(unixTimestamp: number): string {
- const date = new Date(unixTimestamp * 1000);
- return date.toLocaleString('en-US', {
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- hour12: false,
- timeZoneName: 'short'
- });
-}
-
const absoluteTime = formatAbsoluteTime(lastUpdated);
---
diff --git a/status-page/src/components/MonitorCard.astro b/status-page/src/components/MonitorCard.astro
index f349b9e..c2ee774 100644
--- a/status-page/src/components/MonitorCard.astro
+++ b/status-page/src/components/MonitorCard.astro
@@ -1,6 +1,7 @@
---
import type { ApiMonitorStatus } from '@worker/types';
import UptimeBars from './UptimeBars.astro';
+import { formatLastChecked } from '../lib/monitor-card.js';
interface Props {
monitor: ApiMonitorStatus;
@@ -9,25 +10,6 @@ interface Props {
const { monitor } = Astro.props;
const uptimeFormatted = monitor.uptimePercent.toFixed(2);
-
-function formatLastChecked(timestamp: number | undefined): string {
- if (timestamp == null) return 'Never';
- const now = Math.floor(Date.now() / 1000);
- const diffSeconds = now - timestamp;
-
- if (diffSeconds < 60) return '';
- if (diffSeconds < 3600) {
- const minutes = Math.floor(diffSeconds / 60);
- return `${minutes}m ago`;
- }
- if (diffSeconds < 86400) {
- const hours = Math.floor(diffSeconds / 3600);
- return `${hours}h ago`;
- }
- const days = Math.floor(diffSeconds / 86400);
- return `${days}d ago`;
-}
-
const lastCheckedText = formatLastChecked(monitor.lastChecked);
const chartData = JSON.stringify({
diff --git a/status-page/src/components/UptimeBars.astro b/status-page/src/components/UptimeBars.astro
index 404449e..97b8cf3 100644
--- a/status-page/src/components/UptimeBars.astro
+++ b/status-page/src/components/UptimeBars.astro
@@ -1,18 +1,12 @@
---
import type { ApiDayStatus } from '@worker/types';
+import { getBarColor } from '../lib/uptime-bars.js';
interface Props {
dailyHistory: ApiDayStatus[];
}
const { dailyHistory } = Astro.props;
-
-function getBarColor(uptimePercent: number | undefined): string {
- if (uptimePercent == null) return 'no-data';
- if (uptimePercent >= 99.8) return 'up';
- if (uptimePercent >= 95) return 'degraded';
- return 'down';
-}
---
diff --git a/status-page/src/lib/header.test.ts b/status-page/src/lib/header.test.ts
new file mode 100644
index 0000000..9ebdc30
--- /dev/null
+++ b/status-page/src/lib/header.test.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect, afterAll } from 'vitest';
+import { formatAbsoluteTime } from './header.js';
+
+describe('formatAbsoluteTime', () => {
+ const origTZ = process.env.TZ;
+
+ afterAll(() => {
+ process.env.TZ = origTZ;
+ });
+
+ it('formats timestamp in UTC', () => {
+ process.env.TZ = 'UTC';
+ // 1704067200 = 2024-01-01 00:00:00 UTC
+ const result = formatAbsoluteTime(1704067200);
+ expect(result).toContain('Jan');
+ expect(result).toContain('1,');
+ expect(result).toContain('00:00');
+ });
+
+ it('returns month abbreviation in English', () => {
+ process.env.TZ = 'UTC';
+ // 1719792000 = 2024-07-01 00:00:00 UTC
+ const result = formatAbsoluteTime(1719792000);
+ expect(result).toContain('Jul');
+ expect(result).toContain('1,');
+ });
+});
diff --git a/status-page/src/lib/header.ts b/status-page/src/lib/header.ts
new file mode 100644
index 0000000..963f833
--- /dev/null
+++ b/status-page/src/lib/header.ts
@@ -0,0 +1,11 @@
+export function formatAbsoluteTime(unixTimestamp: number): string {
+ const date = new Date(unixTimestamp * 1000);
+ return date.toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ timeZoneName: 'short',
+ });
+}
diff --git a/status-page/src/lib/monitor-card.test.ts b/status-page/src/lib/monitor-card.test.ts
new file mode 100644
index 0000000..c9df698
--- /dev/null
+++ b/status-page/src/lib/monitor-card.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { formatLastChecked } from './monitor-card.js';
+
+describe('formatLastChecked', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('returns "Never" for null/undefined', () => {
+ expect(formatLastChecked(undefined)).toBe('Never');
+ expect(formatLastChecked(null as unknown as undefined)).toBe('Never');
+ });
+
+ it('returns empty string for < 60 seconds', () => {
+ const now = Math.floor(Date.now() / 1000);
+ expect(formatLastChecked(now - 30)).toBe('');
+ expect(formatLastChecked(now - 0)).toBe('');
+ });
+
+ it('returns minutes ago', () => {
+ const now = Math.floor(Date.now() / 1000);
+ expect(formatLastChecked(now - 120)).toBe('2m ago');
+ expect(formatLastChecked(now - 3540)).toBe('59m ago');
+ });
+
+ it('returns hours ago', () => {
+ const now = Math.floor(Date.now() / 1000);
+ expect(formatLastChecked(now - 3600)).toBe('1h ago');
+ expect(formatLastChecked(now - 82800)).toBe('23h ago');
+ });
+
+ it('returns days ago', () => {
+ const now = Math.floor(Date.now() / 1000);
+ expect(formatLastChecked(now - 86400)).toBe('1d ago');
+ expect(formatLastChecked(now - 172800)).toBe('2d ago');
+ });
+});
diff --git a/status-page/src/lib/monitor-card.ts b/status-page/src/lib/monitor-card.ts
new file mode 100644
index 0000000..3885883
--- /dev/null
+++ b/status-page/src/lib/monitor-card.ts
@@ -0,0 +1,17 @@
+export function formatLastChecked(timestamp: number | undefined): string {
+ if (timestamp == null) return 'Never';
+ const now = Math.floor(Date.now() / 1000);
+ const diffSeconds = now - timestamp;
+
+ if (diffSeconds < 60) return '';
+ if (diffSeconds < 3600) {
+ const minutes = Math.floor(diffSeconds / 60);
+ return `${minutes}m ago`;
+ }
+ if (diffSeconds < 86400) {
+ const hours = Math.floor(diffSeconds / 3600);
+ return `${hours}h ago`;
+ }
+ const days = Math.floor(diffSeconds / 86400);
+ return `${days}d ago`;
+}
diff --git a/status-page/src/lib/uptime-bars.test.ts b/status-page/src/lib/uptime-bars.test.ts
new file mode 100644
index 0000000..dcd9f98
--- /dev/null
+++ b/status-page/src/lib/uptime-bars.test.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest';
+import { getBarColor } from './uptime-bars.js';
+
+describe('getBarColor', () => {
+ it('returns "no-data" for null/undefined', () => {
+ expect(getBarColor(undefined)).toBe('no-data');
+ expect(getBarColor(null as unknown as undefined)).toBe('no-data');
+ });
+
+ it('returns "up" for >= 99.8', () => {
+ expect(getBarColor(100)).toBe('up');
+ expect(getBarColor(99.8)).toBe('up');
+ expect(getBarColor(99.81)).toBe('up');
+ });
+
+ it('returns "degraded" for >= 95 and < 99.8', () => {
+ expect(getBarColor(99.7)).toBe('degraded');
+ expect(getBarColor(97.5)).toBe('degraded');
+ expect(getBarColor(95)).toBe('degraded');
+ });
+
+ it('returns "down" for < 95', () => {
+ expect(getBarColor(94.9)).toBe('down');
+ expect(getBarColor(50)).toBe('down');
+ expect(getBarColor(0)).toBe('down');
+ });
+});
diff --git a/status-page/src/lib/uptime-bars.ts b/status-page/src/lib/uptime-bars.ts
new file mode 100644
index 0000000..918e34d
--- /dev/null
+++ b/status-page/src/lib/uptime-bars.ts
@@ -0,0 +1,6 @@
+export function getBarColor(uptimePercent: number | undefined): string {
+ if (uptimePercent == null) return 'no-data';
+ if (uptimePercent >= 99.8) return 'up';
+ if (uptimePercent >= 95) return 'degraded';
+ return 'down';
+}
diff --git a/status-page/vitest.config.ts b/status-page/vitest.config.ts
new file mode 100644
index 0000000..7dd1325
--- /dev/null
+++ b/status-page/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ include: ['src/**/*.test.ts'],
+ },
+});