diff --git a/src/client/components/StatusHistoryBar.tsx b/src/client/components/StatusHistoryBar.tsx index f3b5eb5..299712e 100644 --- a/src/client/components/StatusHistoryBar.tsx +++ b/src/client/components/StatusHistoryBar.tsx @@ -1,14 +1,17 @@ import type { PublicService } from '../api/status'; +import { useMediaQuery } from '../lib/useMediaQuery'; const DAY_MS = 24 * 60 * 60 * 1000; +// Desktop mirrors the API's full 90-day history. Compact screens use the most +// recent 45 days so each daily bar remains legible. const HISTORY_DAYS = 90; -const DAYS_PER_SEGMENT = 2; -const HISTORY_SEGMENTS = HISTORY_DAYS / DAYS_PER_SEGMENT; +const HISTORY_DAYS_COMPACT = 45; +const COMPACT_QUERY = '(max-width: 520px)'; type HistoryEntry = PublicService['history'][number]; -function dayClass(uptimePct: number | null) { - if (uptimePct === null) return 'is-empty'; +function dayClass(uptimePct: number | null | undefined) { + if (uptimePct === null || uptimePct === undefined) return 'is-empty'; if (uptimePct === 100) return 'is-up'; if (uptimePct === 0) return 'is-down'; return 'is-partial'; @@ -23,34 +26,31 @@ function formatDay(day: number) { }).format(new Date(day)); } -function segmentTitle(startDay: number, endDay: number, uptimePct: number | null) { - const dateRange = `${formatDay(startDay)} – ${formatDay(endDay)}`; - return `${dateRange}: ${uptimePct === null ? 'No data' : `${uptimePct.toFixed(1)}% uptime`}`; +function dayTitle(day: number, uptimePct: number | null | undefined) { + const detail = uptimePct === null || uptimePct === undefined ? 'No data' : `${uptimePct.toFixed(1)}% uptime`; + return `${formatDay(day)}: ${detail}`; } export function StatusHistoryBar({ history }: { history: HistoryEntry[] }) { + const compact = useMediaQuery(COMPACT_QUERY); + const windowDays = compact ? HISTORY_DAYS_COMPACT : HISTORY_DAYS; const historyByDay = new Map(history.map((entry) => [entry.day, entry.uptimePct])); const now = new Date(); const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); - const segments = Array.from({ length: HISTORY_SEGMENTS }, (_segment, index) => { - const startDay = today - (HISTORY_DAYS - index * DAYS_PER_SEGMENT - 1) * DAY_MS; - const endDay = startDay + (DAYS_PER_SEGMENT - 1) * DAY_MS; - const values = Array.from({ length: DAYS_PER_SEGMENT }, (_day, dayIndex) => historyByDay.get(startDay + dayIndex * DAY_MS)).filter( - (value): value is number => value !== undefined && value !== null, - ); - const uptimePct = values.length === 0 ? null : Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * 10) / 10; - return { startDay, endDay, uptimePct }; + const days = Array.from({ length: windowDays }, (_day, index) => { + const day = today - (windowDays - 1 - index) * DAY_MS; + return { day, uptimePct: historyByDay.get(day) }; }); return (
-
- {segments.map(({ startDay, endDay, uptimePct }) => ( - +
+ {days.map(({ day, uptimePct }) => ( + ))}
diff --git a/src/client/lib/useMediaQuery.ts b/src/client/lib/useMediaQuery.ts new file mode 100644 index 0000000..8bc79d0 --- /dev/null +++ b/src/client/lib/useMediaQuery.ts @@ -0,0 +1,15 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +export function useMediaQuery(query: string) { + const subscribe = useCallback( + (listener: () => void) => { + const mediaQuery = window.matchMedia(query); + mediaQuery.addEventListener('change', listener); + return () => mediaQuery.removeEventListener('change', listener); + }, + [query], + ); + const getSnapshot = useCallback(() => window.matchMedia(query).matches, [query]); + + return useSyncExternalStore(subscribe, getSnapshot, () => false); +} diff --git a/src/client/pages/StatusPage.tsx b/src/client/pages/StatusPage.tsx index b24fb94..0594a08 100644 --- a/src/client/pages/StatusPage.tsx +++ b/src/client/pages/StatusPage.tsx @@ -101,7 +101,7 @@ export function StatusPage() {
) : statusQuery.isError || !status ? ( - + @@ -197,20 +197,20 @@ export function StatusPage() { const serviceStatus = SERVICE_STATUS[service.status]; return (
-
- - - -
- {service.name} - - {serviceStatus.label} - +
+
+ + + +
+ {service.name} + {serviceStatus.label} +
+
+
+ 90-day uptime + {service.uptime90d === null ? '—' : `${service.uptime90d.toFixed(1)}%`}
-
-
- 90-day uptime - {service.uptime90d === null ? '—' : `${service.uptime90d.toFixed(1)}%`}
diff --git a/src/client/styles.css b/src/client/styles.css index 7dae8bd..815d210 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -1712,11 +1712,9 @@ button { color: var(--muted); } .public-service-row { - display: grid; - grid-template-columns: minmax(190px, 0.7fr) 120px minmax(320px, 1.2fr); - align-items: center; - gap: 24px; - min-height: 128px; + display: flex; + flex-direction: column; + gap: 16px; padding: 22px; } .public-service-row + .public-service-row { @@ -1725,28 +1723,41 @@ button { .public-service-row:hover { background: #fdfefd; } +.public-service-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} .public-service-summary { display: flex; + flex: 1 1 auto; align-items: center; gap: 14px; min-width: 0; } -.public-service-summary > div { +.public-service-identity { + display: flex; + flex: 1 1 auto; + align-items: center; + gap: 20px; min-width: 0; } -.public-service-summary strong { - display: block; +.public-service-identity strong { + min-width: 0; overflow: hidden; font-size: 15px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; } -.public-service-summary .row-status { - margin-top: 7px; +.public-service-identity [data-slot='badge'] { + flex: 0 0 auto; } .public-service-uptime { + flex: 0 0 auto; text-align: right; + white-space: nowrap; } .public-service-uptime span { display: block; @@ -1762,6 +1773,7 @@ button { letter-spacing: -0.5px; } .status-history { + width: 100%; min-width: 0; } .uptime-days { @@ -1773,7 +1785,7 @@ button { } .uptime-day { flex: 1 1 0; - min-width: 1px; + min-width: 2px; border-radius: 2px; background: #e6e8e7; transition: @@ -1830,11 +1842,10 @@ button { background: #fff; } .status-service-skeleton > div { - display: grid; - grid-template-columns: 42px 1fr minmax(240px, 0.8fr); + display: flex; + flex-wrap: wrap; align-items: center; gap: 16px; - min-height: 116px; padding: 22px; } .status-service-skeleton > div + div { @@ -1858,6 +1869,7 @@ button { height: 34px; } .status-service-skeleton b { + width: 100%; height: 32px; } .status-footer { @@ -2070,12 +2082,8 @@ button { grid-column: 2; justify-self: start; } - .public-service-row { - grid-template-columns: 1fr auto; - gap: 20px; - } - .status-history { - grid-column: 1 / -1; + .uptime-days { + gap: 1px; } } @@ -2213,12 +2221,14 @@ button { .public-service-row { padding: 20px 16px; } + .public-service-header { + gap: 12px; + } .public-service-uptime strong { font-size: 15px; } .uptime-days { height: 28px; - gap: 1px; } .status-footer { align-items: flex-start;