diff --git a/site/app.js b/site/app.js
index 988f5fa..2c98c78 100644
--- a/site/app.js
+++ b/site/app.js
@@ -106,8 +106,14 @@ const SHARE_IMAGE_SIZE = {
const SHARE_PIXEL_RATIO = 2;
const SHARE_FILE_NAME = 'github-status-90-day-uptime.png';
const SHARE_FILE_NAME_ALL = 'github-status-all-time-uptime.png';
+const SHARE_FILE_NAME_DAILY = 'github-status-daily-uptime.png';
// The card mirrors the active tab, so the saved file should say which one it is.
-const shareFileName = () => (shareState.view === 'all' ? SHARE_FILE_NAME_ALL : SHARE_FILE_NAME);
+const shareFileName = () =>
+ shareState.view === 'all'
+ ? SHARE_FILE_NAME_ALL
+ : shareState.view === 'daily'
+ ? SHARE_FILE_NAME_DAILY
+ : SHARE_FILE_NAME;
const SHARE_ICON_PATHS = {
ready:
'M5 2.75C5 1.78 5.78 1 6.75 1h5.5C13.22 1 14 1.78 14 2.75v6.5c0 .97-.78 1.75-1.75 1.75h-5.5C5.78 11 5 10.22 5 9.25v-6.5Zm1.75-.25a.25.25 0 0 0-.25.25v6.5c0 .14.11.25.25.25h5.5a.25.25 0 0 0 .25-.25v-6.5a.25.25 0 0 0-.25-.25h-5.5ZM3.75 5A.75.75 0 0 1 4.5 5.75v6c0 .41.34.75.75.75h5a.75.75 0 0 1 0 1.5h-5A2.25 2.25 0 0 1 3 11.75v-6A.75.75 0 0 1 3.75 5Z',
@@ -127,9 +133,11 @@ const shareState = {
imageBlob: null,
imageReady: false,
imagePreparing: false,
- // All-time view: the card mirrors whichever tab is open, so it needs that data too.
- view: '90d',
+ // Line-chart views: the card mirrors whichever tab is open, so it needs that
+ // data too. `daily` and `allTime` are the stats returned by UptimeHistoryChart.
+ view: 'daily',
allTime: null,
+ daily: null,
totalIncidentCount: 0,
};
let shareResetTimeout = null;
@@ -327,8 +335,10 @@ const renderStatusMeta = (lastUpdated, recentIncidentCount) => {
// This line sits above whichever tab is open, so scope it to that tab. It used to
// report the 90-day count unconditionally, which meant the all-time view claimed
- // "69 incidents in last 90 days" over four years of data.
- const allTimeActive = document.querySelector('.hero-panel')?.dataset.uptimeView === 'all';
+ // "69 incidents in last 90 days" over four years of data. The daily view spans the
+ // project's whole life too, so it gets the all-time count.
+ const activeView = document.querySelector('.hero-panel')?.dataset.uptimeView;
+ const allTimeActive = activeView === 'all' || activeView === 'daily';
const count = allTimeActive ? statusMetaState.totalIncidentCount : recentIncidentCount;
const longScope = allTimeActive
? `since ${statusMetaState.projectStartLabel || 'launch'}`
@@ -529,7 +539,7 @@ const drawLegendItem = (ctx, x, y, color, label, isDark) => {
// The all-time card carries the same rolling-uptime line as the page, redrawn on the
// canvas because the on-page version is an SVG the canvas cannot rasterize directly.
-const drawShareLineChart = (ctx, x, y, w, h, series, { isDark, muted, annotations }) => {
+const drawShareLineChart = (ctx, x, y, w, h, series, { isDark, muted, annotations, yScaleMode }) => {
if (!series || series.length < 2) return;
const axisW = 74;
@@ -537,32 +547,30 @@ const drawShareLineChart = (ctx, x, y, w, h, series, { isDark, muted, annotation
const plotW = w - axisW;
const plotH = h - 34;
- let dataMin = 100;
- series.forEach((point) => {
- if (point.uptime < dataMin) dataMin = point.uptime;
- });
- const yMin = Math.max(0, Math.min(85, Math.floor(dataMin / 5) * 5 - 5));
+ // Borrowed from the chart module so the card and the page cannot disagree about the
+ // axis -- including which of the three scales is in play.
+ const yScale = window.UptimeHistoryChart.makeYScale(series, yScaleMode);
+ const yAt = (value) => y + (1 - yScale.ratio(value)) * plotH;
const xMin = series[0].time;
const xSpan = Math.max(1, series[series.length - 1].time - xMin);
- const at = (point) => [
- plotX + ((point.time - xMin) / xSpan) * plotW,
- y + (1 - (Math.min(100, Math.max(yMin, point.uptime)) - yMin) / (100 - yMin)) * plotH,
- ];
+ const at = (point) => [plotX + ((point.time - xMin) / xSpan) * plotW, yAt(point.uptime)];
ctx.save();
ctx.font = '500 20px "IBM Plex Sans", system-ui, sans-serif';
ctx.fillStyle = muted;
ctx.strokeStyle = isDark ? 'rgba(240, 246, 252, 0.12)' : 'rgba(15, 23, 42, 0.1)';
ctx.lineWidth = 1;
- for (let tick = 100; tick >= yMin - 1e-9; tick -= 5) {
- const gy = y + (1 - (tick - yMin) / (100 - yMin)) * plotH;
+ // The card's gridlines need more clearance than the page's: its labels are 20px, not
+ // 12px, and this plot is the taller of the two.
+ yScale.ticks(plotH, 40).forEach((tick) => {
+ const gy = yAt(tick);
ctx.beginPath();
ctx.moveTo(plotX, gy);
ctx.lineTo(plotX + plotW, gy);
ctx.stroke();
- const label = `${tick}%`;
+ const label = yScale.format(tick);
ctx.fillText(label, plotX - 12 - ctx.measureText(label).width, gy + 7);
- }
+ });
const area = ctx.createLinearGradient(0, y, 0, y + plotH);
area.addColorStop(0, `rgba(${IMPACT_SHARE_RGB.none}, 0.34)`);
@@ -597,13 +605,30 @@ const drawShareLineChart = (ctx, x, y, w, h, series, { isDark, muted, annotation
ctx.font = '500 20px "IBM Plex Sans", system-ui, sans-serif';
const start = new Date(xMin);
const end = new Date(series[series.length - 1].time);
- for (let year = start.getUTCFullYear() + 1; year <= end.getUTCFullYear(); year += 1) {
- const time = Date.UTC(year, 0, 1);
- if (time < xMin || time > end.getTime()) continue;
+ // Same rule as the on-page SVG: short ranges tick by month, long ones by year.
+ let axisTicks = [];
+ if (xSpan <= 400 * 86400000) {
+ const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'short', timeZone: 'UTC' });
+ let tickYear = start.getUTCFullYear();
+ let tickMonth = start.getUTCMonth() + 1;
+ for (;;) {
+ const time = Date.UTC(tickYear, tickMonth, 1);
+ if (time > end.getTime()) break;
+ if (time >= xMin) axisTicks.push({ time, label: monthFmt.format(time) });
+ tickMonth += 1;
+ }
+ const stride = Math.ceil(axisTicks.length / 8);
+ if (stride > 1) axisTicks = axisTicks.filter((_, index) => index % stride === 0);
+ } else {
+ for (let year = start.getUTCFullYear() + 1; year <= end.getUTCFullYear(); year += 1) {
+ const time = Date.UTC(year, 0, 1);
+ if (time >= xMin && time <= end.getTime()) axisTicks.push({ time, label: String(year) });
+ }
+ }
+ axisTicks.forEach(({ time, label }) => {
const px = plotX + ((time - xMin) / xSpan) * plotW;
- const label = String(year);
ctx.fillText(label, px - ctx.measureText(label).width / 2, y + plotH + 26);
- }
+ });
// Same peak / low / today callouts the on-page chart carries. The stat strip gives
// the numbers; these say *when*, which is the part the strip cannot show.
@@ -627,17 +652,28 @@ const drawShareLineChart = (ctx, x, y, w, h, series, { isDark, muted, annotation
ctx.fillText(text, tx, py + dy);
};
+ // Month/year locates a 90-day window; a single day needs the day itself.
const fmt = (point) =>
- new Intl.DateTimeFormat(undefined, { month: 'short', year: 'numeric', timeZone: 'UTC' }).format(
- new Date(point.time),
- );
+ new Intl.DateTimeFormat(undefined, {
+ ...(annotations && annotations.daily ? { day: 'numeric' } : {}),
+ month: 'short',
+ year: 'numeric',
+ timeZone: 'UTC',
+ }).format(new Date(point.time));
const rgbOf = (impact) => `rgb(${(isDark ? IMPACT_SHARE_RGB_DARK : IMPACT_SHARE_RGB)[impact]})`;
if (annotations) {
const { peak, low, latest } = annotations;
- annotate(peak, `peak ${peak.uptime.toFixed(2)}% · ${fmt(peak)}`, rgbOf('none'), -18);
+ const lowLabel = annotations.daily ? 'worst day' : 'low';
+ // The daily series pins its peak at 100% for most of its life; no peak there.
+ if (peak && !annotations.daily) {
+ annotate(peak, `peak ${peak.uptime.toFixed(2)}% · ${fmt(peak)}`, rgbOf('none'), -18);
+ }
if (low && Math.abs(low.time - latest.time) > 7 * 86400000) {
- annotate(low, `low ${low.uptime.toFixed(2)}% · ${fmt(low)}`, rgbOf('major'), 32);
+ // A daily low can sit on the baseline; a label below it would land on the
+ // year labels, so flip it above the marker there.
+ const lowDy = at(low)[1] > y + plotH - 40 ? -20 : 32;
+ annotate(low, `${lowLabel} ${low.uptime.toFixed(2)}% · ${fmt(low)}`, rgbOf('major'), lowDy);
}
annotate(latest, `today ${latest.uptime.toFixed(2)}%`, rgbOf(latest.uptime < 99 ? 'major' : 'none'), -18);
}
@@ -745,13 +781,21 @@ const renderShareImageCanvas = () => {
const palette = isDark ? IMPACT_SHARE_RGB_DARK : IMPACT_SHARE_RGB;
const rgb = (impact, alpha) => `rgba(${palette[impact]}, ${alpha})`;
const attribution = 'by Marek Šuppa · @mareksuppa';
+ const daily = shareState.view === 'daily' && shareState.daily;
const allTime = shareState.view === 'all' && shareState.allTime;
+ // Both line-chart tabs (daily and rolling) share the card layout; only the title,
+ // stat tiles and methodology line differ.
+ const lineView = daily || allTime;
ctx.fillStyle = ink;
ctx.font = '700 52px "Space Grotesk", "IBM Plex Sans", sans-serif';
// The card gets shared out of context, so it has to name the platform itself. The
// 90-day card already does, in its "GitHub Platform" row below the title.
- ctx.fillText(allTime ? 'GitHub all-time uptime' : 'Last 90 days uptime', insetX, titleBaselineY);
+ ctx.fillText(
+ daily ? 'GitHub daily uptime' : allTime ? 'GitHub all-time uptime' : 'Last 90 days uptime',
+ insetX,
+ titleBaselineY,
+ );
drawMetaPill(ctx, pillX, pillY, pillWidth, pillHeight, 'Last updated', formatDate(shareState.lastUpdated), isDark);
drawMetaPill(
@@ -760,20 +804,27 @@ const renderShareImageCanvas = () => {
pillY,
pillWidth,
pillHeight,
- allTime ? 'All time' : 'Last 90 days',
- allTime
+ lineView ? 'All time' : 'Last 90 days',
+ lineView
? `${shareState.totalIncidentCount} incidents`
: `${shareState.recentIncidentCount} incident${shareState.recentIncidentCount === 1 ? '' : 's'}`,
isDark,
);
- if (allTime) {
- const stats = [
- [`${allTime.lifetimeUptime.toFixed(2)}%`, 'LIFETIME', ink],
- [`${allTime.peak.uptime.toFixed(2)}%`, 'BEST 90D', rgb('none', 1)],
- [`${allTime.low.uptime.toFixed(2)}%`, 'WORST 90D', rgb('major', 1)],
- [`${allTime.latest.uptime.toFixed(2)}%`, 'TODAY', ink],
- ];
+ if (lineView) {
+ const stats = daily
+ ? [
+ [`${daily.lifetimeUptime.toFixed(2)}%`, (daily.rangeLabel || 'lifetime').toUpperCase(), ink],
+ [`${daily.cleanDays} of ${daily.series.length}`, 'DAYS AT 100%', rgb('none', 1)],
+ [`${daily.low.uptime.toFixed(2)}%`, 'WORST DAY', rgb('major', 1)],
+ [`${daily.latest.uptime.toFixed(2)}%`, (daily.latestLabel || 'today').toUpperCase(), ink],
+ ]
+ : [
+ [`${allTime.lifetimeUptime.toFixed(2)}%`, 'LIFETIME', ink],
+ [`${allTime.peak.uptime.toFixed(2)}%`, 'BEST 90D', rgb('none', 1)],
+ [`${allTime.low.uptime.toFixed(2)}%`, 'WORST 90D', rgb('major', 1)],
+ [`${allTime.latest.uptime.toFixed(2)}%`, 'TODAY', ink],
+ ];
const statGap = 16;
const statW = (cardWidth - 128 - statGap * (stats.length - 1)) / stats.length;
const statY = cardY + 148;
@@ -805,13 +856,23 @@ const renderShareImageCanvas = () => {
statY + statH + 34,
cardWidth - 128,
cardY + cardHeight - 96 - (statY + statH + 34),
- allTime.series,
- { isDark, ink, muted, annotations: allTime },
+ lineView.series,
+ {
+ isDark,
+ ink,
+ muted,
+ annotations: { ...lineView, daily: Boolean(daily) },
+ yScaleMode: (daily && daily.yScaleMode) || 'linear',
+ },
);
ctx.fillStyle = muted;
ctx.font = '500 24px "IBM Plex Sans", system-ui, sans-serif';
- ctx.fillText('90-day rolling window', insetX, cardY + cardHeight - 52);
+ const dailyFooter =
+ daily && daily.rangeLabel !== 'lifetime'
+ ? `${daily.cardFooter || 'uptime per UTC day'} · ${daily.rangeLabel}`
+ : daily && (daily.cardFooter || 'uptime per UTC day');
+ ctx.fillText(daily ? dailyFooter : '90-day rolling window', insetX, cardY + cardHeight - 52);
ctx.font = '600 24px "IBM Plex Sans", system-ui, sans-serif';
ctx.fillText(attribution, insetRight - ctx.measureText(attribution).width, cardY + cardHeight - 52);
return canvas;
@@ -1029,20 +1090,186 @@ const renderAllTimeUptime = (windowEntries, rangeEnd) => {
return stats;
};
+// Work hours are evaluated in the viewer's timezone. There is no region control:
+// the status feed carries no structured geography (only ~5% of incidents even name
+// a region in prose), so a region filter could not report anything accurate.
+const DAILY_WORK_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+// Date ranges for the daily chart. Multi-year daily data compresses every dip into
+// a wall of spikes, so the chart defaults to a recent window with the full history
+// one selection away.
+const DAILY_RANGES = {
+ '2m': { months: 2, label: 'last 2 months' },
+ '6m': { months: 6, label: 'last 6 months' },
+ '1y': { months: 12, label: 'last year' },
+ all: { months: null, label: 'lifetime' },
+};
+
+// Spoken form of each y-axis mode, for the chart's aria-label. The axis is a display
+// choice, so it stays off the caption and the share-card footer.
+const Y_SCALE_LABELS = {
+ 'log-uptime': 'logarithmic uptime axis',
+ 'log-downtime': 'logarithmic downtime axis',
+ linear: 'linear axis',
+};
+
+const parseTimeInputMinutes = (value) => {
+ const match = /^(\d{2}):(\d{2})$/.exec(value || '');
+ return match ? Number(match[1]) * 60 + Number(match[2]) : null;
+};
+
+// Reads the daily filter controls into a workWindow for the chart module plus the
+// human-readable strings (caption, tooltip, share-card footer) describing it.
+// Returns null when no filter is active.
+const readDailyFilters = () => {
+ const workToggle = document.getElementById('dailyWorkHoursToggle');
+ const weekdaysToggle = document.getElementById('dailyWeekdaysToggle');
+ const startInput = document.getElementById('dailyWorkStart');
+ const endInput = document.getElementById('dailyWorkEnd');
+
+ const weekdaysOnly = Boolean(weekdaysToggle && weekdaysToggle.checked);
+ const hoursActive = Boolean(workToggle && workToggle.checked);
+ const startMinutes = hoursActive ? parseTimeInputMinutes(startInput && startInput.value) : null;
+ const endMinutes = hoursActive ? parseTimeInputMinutes(endInput && endInput.value) : null;
+ const hoursValid =
+ hoursActive && startMinutes !== null && endMinutes !== null && endMinutes > startMinutes;
+ // An inverted range (end before start) cannot mean anything; flag it and fall
+ // back to full days rather than rendering an empty chart.
+ if (endInput) endInput.setAttribute('aria-invalid', String(hoursActive && !hoursValid));
+
+ if (!hoursValid && !weekdaysOnly) return null;
+
+ const hoursLabel = hoursValid
+ ? `${startInput.value}–${endInput.value} (${DAILY_WORK_TIME_ZONE})`
+ : null;
+ const scopeLabel = hoursLabel
+ ? `uptime ${hoursLabel}${weekdaysOnly ? ', weekdays' : ''}`
+ : `uptime per UTC day, weekdays only`;
+
+ return {
+ workWindow: {
+ startMinutes: hoursValid ? startMinutes : 0,
+ endMinutes: hoursValid ? endMinutes : 24 * 60,
+ timeZone: hoursValid ? DAILY_WORK_TIME_ZONE : 'UTC',
+ weekdaysOnly,
+ },
+ caption: `${scopeLabel} · non-maintenance downtime, merged windows`,
+ tooltipScope: hoursLabel ? `uptime ${hoursLabel} this day` : null,
+ cardFooter: scopeLabel,
+ latestLabel: weekdaysOnly ? 'latest day' : 'today',
+ };
+};
+
+const renderDailyUptime = (windowEntries, rangeEnd) => {
+ if (!window.UptimeHistoryChart || typeof window.UptimeHistoryChart.render !== 'function') {
+ console.warn('UptimeHistoryChart module not loaded; skipping daily render.');
+ return null;
+ }
+ const filters = readDailyFilters();
+ // A display choice rather than a filter on the data, so it stays out of
+ // readDailyFilters and off the caption and share-card footer.
+ const yScaleSelect = document.getElementById('dailyYScale');
+ const yScaleMode = (yScaleSelect && yScaleSelect.value) || 'log-uptime';
+
+ const rangeSelect = document.getElementById('dailyRange');
+ // Fallback matches the option marked selected in the markup.
+ const range = DAILY_RANGES[rangeSelect && rangeSelect.value] || DAILY_RANGES['2m'];
+ let projectStartUTC;
+ if (range.months) {
+ const end = new Date(rangeEnd instanceof Date ? rangeEnd.getTime() : rangeEnd);
+ projectStartUTC = Math.max(
+ window.UptimeHistoryChart.PROJECT_START_UTC,
+ Date.UTC(end.getUTCFullYear(), end.getUTCMonth() - range.months, end.getUTCDate()),
+ );
+ }
+
+ const stats = window.UptimeHistoryChart.render(windowEntries, rangeEnd, {
+ windowDays: 1,
+ chartTarget: '#uptimeDailyImage',
+ captionTarget: '#uptimeDailyCaption',
+ caption: filters ? filters.caption : null,
+ tooltipScope: filters ? filters.tooltipScope : null,
+ workWindow: filters ? filters.workWindow : null,
+ projectStartUTC,
+ yScaleMode,
+ ariaLabel: `GitHub Platform daily uptime, ${range.label}, ${Y_SCALE_LABELS[yScaleMode]}`,
+ });
+ const host = document.getElementById('dailyStats');
+ if (!stats || !stats.series.length) return null;
+
+ const latestLabel = filters ? filters.latestLabel : 'today';
+ // "Best day" would read 100.00% for most of the project's life, so the strip
+ // shows how often that happens instead: the count of days with zero downtime.
+ const cleanDays = stats.series.filter((point) => point.uptime === 100).length;
+ if (host) {
+ host.replaceChildren(
+ buildAllTimeStat(`${stats.lifetimeUptime.toFixed(2)}%`, range.label),
+ buildAllTimeStat(`${cleanDays} of ${stats.series.length}`, 'days at 100%', 'good'),
+ buildAllTimeStat(`${stats.low.uptime.toFixed(2)}%`, 'worst day', 'bad'),
+ buildAllTimeStat(`${stats.latest.uptime.toFixed(2)}%`, latestLabel),
+ );
+ }
+ return {
+ ...stats,
+ cleanDays,
+ yScaleMode,
+ cardFooter: filters ? filters.cardFooter : 'uptime per UTC day',
+ latestLabel,
+ rangeLabel: range.label,
+ };
+};
+
+// The daily filters re-render the chart from the already-fetched data; render()
+// stows it here so the change handlers do not need to refetch.
+let dailyRenderData = null;
+
+const setupDailyFilters = () => {
+ const workToggle = document.getElementById('dailyWorkHoursToggle');
+ const weekdaysToggle = document.getElementById('dailyWeekdaysToggle');
+ const yScaleSelect = document.getElementById('dailyYScale');
+ const rangeSelect = document.getElementById('dailyRange');
+ const hourControls = [
+ document.getElementById('dailyWorkStart'),
+ document.getElementById('dailyWorkEnd'),
+ ];
+ if (!workToggle) return;
+
+ const applyFilters = () => {
+ hourControls.forEach((el) => {
+ if (el) el.disabled = !workToggle.checked;
+ });
+ if (!dailyRenderData) return;
+ shareState.daily =
+ renderDailyUptime(dailyRenderData.windowEntries, dailyRenderData.rangeEnd) || null;
+ // The share card mirrors the daily tab, so the cached PNG is stale now.
+ if (shareState.view === 'daily') {
+ shareState.imageBlob = null;
+ shareState.imageReady = false;
+ if (shareState.daySeverity.length) scheduleSharePrime();
+ }
+ };
+
+ [workToggle, weekdaysToggle, yScaleSelect, rangeSelect, ...hourControls].forEach((el) => {
+ if (el) el.addEventListener('change', applyFilters);
+ });
+};
+
const setupUptimeViewToggle = () => {
const panel = document.querySelector('.hero-panel');
const toggle = document.getElementById('uptimeViewToggle');
const buttons = toggle ? Array.from(toggle.querySelectorAll('.view-toggle-btn')) : [];
const views = {
+ daily: document.getElementById('uptimeViewDaily'),
'90d': document.getElementById('uptimeView90d'),
all: document.getElementById('uptimeViewAll'),
};
const title = document.getElementById('uptimeViewTitle');
const titleByView = {
+ daily: 'Daily uptime',
'90d': 'Last 90 days uptime',
- all: 'All-time uptime',
+ all: 'All-time uptime (90-day rolling)',
};
- if (!panel || !toggle || buttons.length === 0 || !views['90d'] || !views.all) {
+ if (!panel || !toggle || buttons.length === 0 || !views.daily || !views['90d'] || !views.all) {
return;
}
@@ -1065,8 +1292,10 @@ const setupUptimeViewToggle = () => {
rerenderStatusMeta();
// The chart sizes its viewBox to the container, which measures zero while the
// tabpanel is hidden. Redraw at the real width the moment it is shown.
- if (view === 'all') {
- const chart = document.getElementById('uptimeHistoryImage');
+ if (view === 'all' || view === 'daily') {
+ const chart = document.getElementById(
+ view === 'all' ? 'uptimeHistoryImage' : 'uptimeDailyImage',
+ );
if (chart && typeof chart.redrawUptimeHistory === 'function') chart.redrawUptimeHistory();
}
// The card mirrors the active tab, so the cached PNG is stale the moment the
@@ -1097,7 +1326,7 @@ const setupUptimeViewToggle = () => {
});
});
- setView('90d');
+ setView('daily');
};
const render = async () => {
@@ -1187,6 +1416,8 @@ const render = async () => {
uptimePercent.textContent = `${(uptime * 100).toFixed(2)}% uptime`;
shareState.allTime = renderAllTimeUptime(windowEntries, rangeEnd) || null;
+ dailyRenderData = { windowEntries, rangeEnd };
+ shareState.daily = renderDailyUptime(windowEntries, rangeEnd) || null;
const uptimeBars = document.getElementById('uptimeBars');
const uptimeTooltip = document.getElementById('uptimeTooltip');
@@ -1845,6 +2076,7 @@ const renderIncidentCard = (incident, compact = false) => {
};
setupUptimeViewToggle();
+setupDailyFilters();
render().catch((error) => {
console.error(error);
diff --git a/site/index.html b/site/index.html
index 1e0c6d3..b25bd66 100644
--- a/site/index.html
+++ b/site/index.html
@@ -67,9 +67,9 @@
The Missing GitHub Status Page
-
+
+
diff --git a/site/styles.css b/site/styles.css
index af8df67..f11fc62 100644
--- a/site/styles.css
+++ b/site/styles.css
@@ -941,6 +941,52 @@ main {
}
}
+/* Work-day filters on the daily view. Muted chrome so the chart stays the focus;
+ the time/zone inputs light up only while the work-hours toggle is on. */
+.daily-filters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 14px;
+ font-size: 0.82rem;
+ color: var(--muted);
+}
+
+.daily-filter-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ font-weight: 600;
+}
+
+.daily-filter-item input[type='checkbox'] {
+ accent-color: var(--accent);
+}
+
+.daily-filter-controls,
+/* Keeps a label and its select on the same line when the row wraps. */
+.daily-filter-group {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.daily-filters input[type='time'],
+.daily-filters select {
+ font: inherit;
+ color: var(--ink);
+ background: var(--icon-button-bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 3px 6px;
+}
+
+.daily-filters input[type='time']:disabled,
+.daily-filters select:disabled {
+ opacity: 0.45;
+}
+
.uptime-history-figure {
margin: 0;
display: flex;
diff --git a/site/uptime-history-chart.js b/site/uptime-history-chart.js
index 3dbaf5d..f326ee2 100644
--- a/site/uptime-history-chart.js
+++ b/site/uptime-history-chart.js
@@ -19,6 +19,11 @@ const uptimeHistoryDateFormatter = new Intl.DateTimeFormat(undefined, {
timeZone: 'UTC',
});
+const uptimeHistoryMonthFormatter = new Intl.DateTimeFormat(undefined, {
+ month: 'short',
+ timeZone: 'UTC',
+});
+
const formatUptimeHistoryDate = (value) =>
uptimeHistoryDateFormatter.format(value instanceof Date ? value : new Date(value));
@@ -40,51 +45,19 @@ const collectUptimeHistoryIntervals = (entries) =>
)
.sort((a, b) => a[0] - b[0]);
-const computeUptimeHistorySeries = (intervals, startMs, endMs) => {
- const series = [];
- for (let dayStart = startMs; dayStart < endMs; dayStart += UPTIME_HISTORY_ONE_DAY_MS) {
- const windowEnd = dayStart + UPTIME_HISTORY_ONE_DAY_MS;
- const windowStart = windowEnd - UPTIME_HISTORY_WINDOW_MS;
-
- let downtimeMs = 0;
- let mergedStart = -1;
- let mergedEnd = -1;
- for (let i = 0; i < intervals.length; i += 1) {
- const [entryStart, entryEnd] = intervals[i];
- if (entryEnd <= windowStart) continue;
- if (entryStart >= windowEnd) break;
- const clipStart = entryStart > windowStart ? entryStart : windowStart;
- const clipEnd = entryEnd < windowEnd ? entryEnd : windowEnd;
- if (clipEnd <= clipStart) continue;
- if (mergedStart < 0) {
- mergedStart = clipStart;
- mergedEnd = clipEnd;
- } else if (clipStart <= mergedEnd) {
- if (clipEnd > mergedEnd) mergedEnd = clipEnd;
- } else {
- downtimeMs += mergedEnd - mergedStart;
- mergedStart = clipStart;
- mergedEnd = clipEnd;
- }
- }
- if (mergedStart >= 0) downtimeMs += mergedEnd - mergedStart;
-
- const uptime = Math.max(0, 1 - downtimeMs / UPTIME_HISTORY_WINDOW_MS) * 100;
- series.push({ time: dayStart, uptime });
- }
- return series;
-};
-
-const computeLifetimeUptime = (intervals, startMs, endMs) => {
+// Merged downtime inside [windowStart, windowEnd). Intervals must be sorted by
+// start; overlapping entries are merged as they are clipped so double-reported
+// incidents never count twice.
+const uptimeHistoryDowntimeWithin = (intervals, windowStart, windowEnd) => {
let downtimeMs = 0;
let mergedStart = -1;
let mergedEnd = -1;
for (let i = 0; i < intervals.length; i += 1) {
const [entryStart, entryEnd] = intervals[i];
- if (entryEnd <= startMs) continue;
- if (entryStart >= endMs) break;
- const clipStart = entryStart > startMs ? entryStart : startMs;
- const clipEnd = entryEnd < endMs ? entryEnd : endMs;
+ if (entryEnd <= windowStart) continue;
+ if (entryStart >= windowEnd) break;
+ const clipStart = entryStart > windowStart ? entryStart : windowStart;
+ const clipEnd = entryEnd < windowEnd ? entryEnd : windowEnd;
if (clipEnd <= clipStart) continue;
if (mergedStart < 0) {
mergedStart = clipStart;
@@ -98,14 +71,287 @@ const computeLifetimeUptime = (intervals, startMs, endMs) => {
}
}
if (mergedStart >= 0) downtimeMs += mergedEnd - mergedStart;
+ return downtimeMs;
+};
+// windowMs is the trailing window each point summarizes: 90 days for the rolling
+// view, one day for the daily view (where the window IS the day itself).
+const computeUptimeHistorySeries = (intervals, startMs, endMs, windowMs = UPTIME_HISTORY_WINDOW_MS) => {
+ const series = [];
+ for (let dayStart = startMs; dayStart < endMs; dayStart += UPTIME_HISTORY_ONE_DAY_MS) {
+ const windowEnd = dayStart + UPTIME_HISTORY_ONE_DAY_MS;
+ const windowStart = windowEnd - windowMs;
+ const downtimeMs = uptimeHistoryDowntimeWithin(intervals, windowStart, windowEnd);
+ const uptime = Math.max(0, 1 - downtimeMs / windowMs) * 100;
+ series.push({ time: dayStart, uptime });
+ }
+ return series;
+};
+
+// Wall-clock time in an arbitrary IANA zone -> epoch ms, DST-aware. Formatters are
+// cached because the work-hours series calls this twice per day over ~1500 days.
+const uptimeHistoryTzFormatters = new Map();
+const uptimeHistoryTzFormatter = (timeZone) => {
+ let formatter = uptimeHistoryTzFormatters.get(timeZone);
+ if (!formatter) {
+ formatter = new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ hourCycle: 'h23',
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ second: 'numeric',
+ });
+ uptimeHistoryTzFormatters.set(timeZone, formatter);
+ }
+ return formatter;
+};
+
+const uptimeHistoryTzOffsetMs = (ts, timeZone) => {
+ const values = {};
+ uptimeHistoryTzFormatter(timeZone)
+ .formatToParts(ts)
+ .forEach((part) => {
+ if (part.type !== 'literal') values[part.type] = Number(part.value);
+ });
+ const asUTC = Date.UTC(
+ values.year,
+ values.month - 1,
+ values.day,
+ values.hour,
+ values.minute,
+ values.second,
+ );
+ return asUTC - ts;
+};
+
+const uptimeHistoryZonedMs = (year, month, day, minutes, timeZone) => {
+ const naive = Date.UTC(year, month, day, 0, minutes);
+ // First guess assumes the zone's offset at the naive instant; the second pass
+ // corrects the guess when the window straddles a DST transition.
+ let ts = naive - uptimeHistoryTzOffsetMs(naive, timeZone);
+ ts = naive - uptimeHistoryTzOffsetMs(ts, timeZone);
+ return ts;
+};
+
+// Daily series restricted to working hours: each point covers only
+// [startMinutes, endMinutes) of its calendar day, evaluated in `timeZone`.
+// Points carry their downtime and window length so the caller can aggregate a
+// work-hours lifetime figure without recomputing.
+const computeWorkHoursDailySeries = (intervals, startMs, endMs, workWindow) => {
+ const { startMinutes, endMinutes, timeZone, weekdaysOnly } = workWindow;
+ const series = [];
+ for (let dayStart = startMs; dayStart < endMs; dayStart += UPTIME_HISTORY_ONE_DAY_MS) {
+ const date = new Date(dayStart);
+ if (weekdaysOnly) {
+ const weekday = date.getUTCDay();
+ if (weekday === 0 || weekday === 6) continue;
+ }
+ const windowStart = uptimeHistoryZonedMs(
+ date.getUTCFullYear(),
+ date.getUTCMonth(),
+ date.getUTCDate(),
+ startMinutes,
+ timeZone,
+ );
+ const windowEnd = uptimeHistoryZonedMs(
+ date.getUTCFullYear(),
+ date.getUTCMonth(),
+ date.getUTCDate(),
+ endMinutes,
+ timeZone,
+ );
+ const windowMs = windowEnd - windowStart;
+ if (windowMs <= 0) continue;
+ const downtimeMs = uptimeHistoryDowntimeWithin(intervals, windowStart, windowEnd);
+ const uptime = Math.max(0, 1 - downtimeMs / windowMs) * 100;
+ series.push({ time: dayStart, uptime, downtimeMs, windowMs });
+ }
+ return series;
+};
+
+const computeLifetimeUptime = (intervals, startMs, endMs) => {
+ const downtimeMs = uptimeHistoryDowntimeWithin(intervals, startMs, endMs);
const totalMs = Math.max(1, endMs - startMs);
return Math.max(0, 1 - downtimeMs / totalMs) * 100;
};
-const buildUptimeHistorySVG = (series, layout = UPTIME_HISTORY_LAYOUT) => {
+// The three y axes the daily view offers. Each answers a different question:
+// linear -- how much of the day was up, read straight off the axis
+// log-uptime -- how bad was a bad day: 3% and 0.3% separate, 90-100% squeezes
+// log-downtime -- how good was a good day: the "nines", where 99.9% and 99% separate
+const UPTIME_Y_SCALE_MODES = ['linear', 'log-uptime', 'log-downtime'];
+
+// Gridline candidates for the log-uptime axis, densest where that axis stretches.
+// Thinned to whatever the plot height can fit; the linear axis keeps its own even step.
+const UPTIME_LOG_TICK_LADDER = [1, 2, 3, 5, 7, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100];
+
+// 1-2-5 per decade, the conventional log ladder. Used in *downtime* percent, so
+// 0.01 is "four nines" and 100 is a day that was down from end to end.
+const decadeLadder = (min, max) => {
+ const out = [];
+ for (let exp = -3; exp <= 2; exp += 1) {
+ [1, 2, 5].forEach((mantissa) => {
+ const value = Number((mantissa * Math.pow(10, exp)).toPrecision(12));
+ if (value >= min - 1e-12 && value <= max + 1e-12) out.push(value);
+ });
+ }
+ return out;
+};
+
+const powerOfTenAtOrBelow = (value) => Math.pow(10, Math.floor(Math.log10(value)));
+
+// Keep ticks from the top down, dropping any that would crowd the one above it. The
+// axis floor is never dropped -- it labels the end of the scale -- so whatever crowds
+// it goes instead.
+const thinTicks = (candidates, ratio, plotH, minGapPx) => {
+ const kept = [];
+ candidates.forEach((value, index) => {
+ const isFloor = index === candidates.length - 1;
+ const gap = kept.length ? (ratio(kept[kept.length - 1]) - ratio(value)) * plotH : Infinity;
+ if (gap >= minGapPx) {
+ kept.push(value);
+ } else if (isFloor) {
+ while (kept.length > 1 && (ratio(kept[kept.length - 1]) - ratio(value)) * plotH < minGapPx) {
+ kept.pop();
+ }
+ kept.push(value);
+ }
+ });
+ return kept;
+};
+
+// The y axis for both renderers: a domain sized off the data, a bottom-anchored 0..1
+// position for a value, the tick values, and how to label them.
+const makeUptimeYScale = (series, mode = 'linear') => {
+ const yMax = 100;
+ let dataMin = 100;
+ let minDowntime = Infinity; // smallest dip that is not a perfect day
+ let maxDowntime = 0;
+ series.forEach((point) => {
+ if (point.uptime < dataMin) dataMin = point.uptime;
+ const downtime = 100 - point.uptime;
+ if (downtime > 0 && downtime < minDowntime) minDowntime = downtime;
+ if (downtime > maxDowntime) maxDowntime = downtime;
+ });
+
+ if (mode === 'log-downtime') {
+ // Position tracks log10(downtime), inverted so 100% stays at the top. log10(0) is
+ // undefined and perfect days are the common case, so the axis stops one decade
+ // below the smallest real dip: only a zero-downtime day can reach the top line,
+ // which is why that line is labelled a flat 100%.
+ const top = Number.isFinite(minDowntime)
+ ? Math.min(1, Math.max(0.001, powerOfTenAtOrBelow(minDowntime)))
+ : 0.01;
+ const ladder = decadeLadder(top, 100);
+ const bottom =
+ maxDowntime > 0
+ ? ladder.find((value) => value >= maxDowntime - 1e-12) || 100
+ : Math.min(1, top * 100);
+ const logTop = Math.log10(top);
+ const logBottom = Math.log10(bottom);
+ const span = logBottom - logTop;
+
+ const ratio = (value) => {
+ const downtime = Math.min(bottom, Math.max(top, 100 - value));
+ return (logBottom - Math.log10(downtime)) / span;
+ };
+
+ return {
+ yMin: 100 - bottom,
+ yMax,
+ mode,
+ ratio,
+ ticks: (plotH, minGapPx = 26) => {
+ const ladder = decadeLadder(top, bottom).filter((downtime) => downtime > top + 1e-12);
+ const fits = (value, kept) =>
+ kept.every((other) => Math.abs(ratio(value) - ratio(other)) * plotH >= minGapPx);
+ // The nines themselves -- 99.9, 99, 90 -- are the labels this axis exists for,
+ // so the decades are placed first and the 2/5 steps only fill what is left.
+ // 100 rather than 100 - top: the two share a pixel row, and 100 is both the
+ // shorter label and the truthful one for everything that can land there.
+ const kept = [100];
+ ladder
+ .filter((downtime) => Math.abs(Math.log10(downtime) % 1) < 1e-9)
+ .concat(bottom)
+ .forEach((downtime) => {
+ const value = 100 - downtime;
+ if (!kept.includes(value) && fits(value, kept)) kept.push(value);
+ });
+ ladder.forEach((downtime) => {
+ const value = 100 - downtime;
+ if (!kept.includes(value) && fits(value, kept)) kept.push(value);
+ });
+ return kept.sort((a, b) => b - a);
+ },
+ // Enough decimals to tell one gridline from the next, and no more: 99.99, 99.9,
+ // 99, 90, 0. toPrecision first, or 100 - 99.9 = 0.09999... asks for one decimal
+ // too many.
+ format: (value) => {
+ const downtime = Number((100 - value).toPrecision(12));
+ const decimals =
+ downtime > 0 ? Math.max(0, Math.min(3, Math.ceil(-Math.log10(downtime)))) : 0;
+ return `${value.toFixed(decimals)}%`;
+ },
+ };
+ }
+
+ const logUptime = mode === 'log-uptime';
+ // Same floor either way, so switching between these two redistributes the axis
+ // without also moving its ends.
+ const linearMin = Math.max(0, Math.min(85, Math.floor(dataMin / 5) * 5 - 5));
+ // A full-day outage reads 0% and log10(0) is undefined, so the log domain stops at 1%
+ // and anything below it clamps to the baseline.
+ const yMin = logUptime ? Math.max(1, linearMin) : linearMin;
+ const logMin = Math.log10(yMin);
+ const span = logUptime ? Math.log10(yMax) - logMin : yMax - yMin;
+
+ const ratio = (value) => {
+ const clamped = Math.min(yMax, Math.max(yMin, value));
+ return logUptime ? (Math.log10(clamped) - logMin) / span : (clamped - yMin) / span;
+ };
+
+ return {
+ yMin,
+ yMax,
+ mode: logUptime ? 'log-uptime' : 'linear',
+ ratio,
+ ticks: (plotH, minGapPx = 26) => {
+ if (!logUptime) {
+ // A fixed 5% step is right for the rolling view's few-percent span but draws 21
+ // gridlines across a 0-100% one, so pick the smallest step that lands on both
+ // edges with at most 8 lines.
+ const step = [5, 10, 20, 25].find((s) => span % s === 0 && span / s <= 8) || 5;
+ const out = [];
+ for (let tick = yMax; tick >= yMin - 1e-9; tick -= step) out.push(tick);
+ return out;
+ }
+ const candidates = UPTIME_LOG_TICK_LADDER.filter((v) => v > yMin && v <= yMax)
+ .sort((a, b) => b - a)
+ .concat(yMin);
+ return thinTicks(candidates, ratio, plotH, minGapPx);
+ },
+ format: (value) => `${value.toFixed(0)}%`,
+ };
+};
+
+const buildUptimeHistorySVG = (series, layout = UPTIME_HISTORY_LAYOUT, opts = {}) => {
if (!series.length) return { markup: '', geometry: null };
+ const {
+ ariaLabel = 'GitHub Platform 90-day rolling uptime since project start',
+ // The daily series pins its peak at 100% for most of its life, so a "peak"
+ // annotation says nothing there; the rolling view keeps it.
+ showPeak = true,
+ lowLabel = 'low',
+ // Two SVGs share the page (daily and rolling); gradient ids must not collide or
+ // the hidden chart's defs would answer the visible chart's url() references.
+ gradientId = 'uptimeHistory',
+ yScaleMode = 'linear',
+ } = opts;
+
const { width, height, marginLeft, marginRight, marginTop, marginBottom } = layout;
const plotW = width - marginLeft - marginRight;
const plotH = height - marginTop - marginBottom;
@@ -114,19 +360,12 @@ const buildUptimeHistorySVG = (series, layout = UPTIME_HISTORY_LAYOUT) => {
const xMax = series[series.length - 1].time;
const xSpan = Math.max(1, xMax - xMin);
- let dataMin = 100;
- for (let i = 0; i < series.length; i += 1) {
- if (series[i].uptime < dataMin) dataMin = series[i].uptime;
- }
- const yMin = Math.max(0, Math.min(85, Math.floor(dataMin / 5) * 5 - 5));
- const yMax = 100;
+ const yScale = makeUptimeYScale(series, yScaleMode);
+ const { yMin, yMax } = yScale;
const ySpan = yMax - yMin;
const xAt = (time) => marginLeft + ((time - xMin) / xSpan) * plotW;
- const yAt = (value) => {
- const clamped = Math.min(yMax, Math.max(yMin, value));
- return marginTop + (1 - (clamped - yMin) / ySpan) * plotH;
- };
+ const yAt = (value) => marginTop + (1 - yScale.ratio(value)) * plotH;
const baselineY = marginTop + plotH;
const points = series
@@ -139,15 +378,33 @@ const buildUptimeHistorySVG = (series, layout = UPTIME_HISTORY_LAYOUT) => {
.join(' ') +
` L ${xAt(series[series.length - 1].time).toFixed(2)},${baselineY.toFixed(2)} Z`;
- const yTicks = [];
- for (let tick = 100; tick >= yMin - 1e-9; tick -= 5) yTicks.push(tick);
+ const yTicks = yScale.ticks(plotH);
const startDate = new Date(xMin);
const endDate = new Date(xMax);
- const xTicks = [];
- for (let year = startDate.getUTCFullYear(); year <= endDate.getUTCFullYear(); year += 1) {
- const candidate = Date.UTC(year, 0, 1);
- if (candidate >= xMin && candidate <= xMax) xTicks.push({ year, time: candidate });
+ // Year ticks label a multi-year span but leave a two-month window with a bare
+ // axis, so short ranges tick by month instead (thinned to at most 8 labels).
+ let xTicks = [];
+ if (xSpan / UPTIME_HISTORY_ONE_DAY_MS <= 400) {
+ let year = startDate.getUTCFullYear();
+ let month = startDate.getUTCMonth() + 1;
+ for (;;) {
+ const candidate = Date.UTC(year, month, 1);
+ if (candidate > xMax) break;
+ if (candidate >= xMin) {
+ xTicks.push({ label: uptimeHistoryMonthFormatter.format(candidate), time: candidate });
+ }
+ month += 1;
+ }
+ const stride = Math.ceil(xTicks.length / 8);
+ if (stride > 1) xTicks = xTicks.filter((_, index) => index % stride === 0);
+ } else {
+ for (let year = startDate.getUTCFullYear(); year <= endDate.getUTCFullYear(); year += 1) {
+ const candidate = Date.UTC(year, 0, 1);
+ if (candidate >= xMin && candidate <= xMax) {
+ xTicks.push({ label: String(year), time: candidate });
+ }
+ }
}
const lastPoint = series[series.length - 1];
@@ -189,16 +446,16 @@ const buildUptimeHistorySVG = (series, layout = UPTIME_HISTORY_LAYOUT) => {
parts.push(
`
');
return {
markup: parts.join(''),
- geometry: { marginLeft, marginTop, plotW, plotH, xMin, xSpan, yMin, yMax, width, height },
+ geometry: {
+ marginLeft,
+ marginTop,
+ plotW,
+ plotH,
+ xMin,
+ xSpan,
+ yMin,
+ yMax,
+ // The cursor has to land on the line it is tracking, so it shares the scale
+ // rather than assuming the axis is linear.
+ yRatio: yScale.ratio,
+ width,
+ height,
+ },
};
};
@@ -340,18 +620,30 @@ const nearestUptimePoint = (series, geometry, clientX, svgEl) => {
const localX = (clientX - box.left) / scale;
const ratio = (localX - geometry.marginLeft) / geometry.plotW;
const clamped = Math.min(1, Math.max(0, ratio));
- const index = Math.round(clamped * (series.length - 1));
+ // Search by time, not by index-as-ratio: the weekdays-only series has weekend
+ // gaps, so points are not uniformly spaced along the x axis.
+ const target = geometry.xMin + clamped * geometry.xSpan;
+ let lo = 0;
+ let hi = series.length - 1;
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1;
+ if (series[mid].time < target) lo = mid + 1;
+ else hi = mid;
+ }
+ const index =
+ lo > 0 && target - series[lo - 1].time <= series[lo].time - target ? lo - 1 : lo;
return { index, point: series[index] };
};
-// Clicking a point scopes the incident timeline to the 90-day window that produced
-// it, turning "there is a dip in May 2026" into the incidents that caused it.
-const focusTimelineOn = (point) => {
+// Clicking a point scopes the incident timeline to the window that produced it,
+// turning "there is a dip in May 2026" into the incidents that caused it. For the
+// daily view that window is the single clicked day.
+const focusTimelineOn = (point, windowDays = UPTIME_HISTORY_WINDOW_DAYS) => {
const from = document.querySelector('[data-range-from]');
const to = document.querySelector('[data-range-to]');
if (!from || !to) return false;
const end = new Date(point.time);
- const start = new Date(point.time - (UPTIME_HISTORY_WINDOW_DAYS - 1) * UPTIME_HISTORY_ONE_DAY_MS);
+ const start = new Date(point.time - (windowDays - 1) * UPTIME_HISTORY_ONE_DAY_MS);
const iso = (d) => d.toISOString().slice(0, 10);
from.value = iso(start);
to.value = iso(end);
@@ -361,7 +653,8 @@ const focusTimelineOn = (point) => {
return true;
};
-const attachUptimeHistoryInteraction = (container, series, geometry) => {
+const attachUptimeHistoryInteraction = (container, series, geometry, opts = {}) => {
+ const windowDays = opts.windowDays || UPTIME_HISTORY_WINDOW_DAYS;
const svgEl = container.querySelector('svg');
const cursor = svgEl && svgEl.querySelector('.uptime-cursor');
const line = cursor && cursor.querySelector('.uptime-cursor-line');
@@ -389,19 +682,21 @@ const attachUptimeHistoryInteraction = (container, series, geometry) => {
line.setAttribute('x1', cx.toFixed(2));
line.setAttribute('x2', cx.toFixed(2));
- const clamped = Math.min(geometry.yMax, Math.max(geometry.yMin, point.uptime));
- const cy =
- geometry.marginTop +
- (1 - (clamped - geometry.yMin) / (geometry.yMax - geometry.yMin)) * geometry.plotH;
+ const cy = geometry.marginTop + (1 - geometry.yRatio(point.uptime)) * geometry.plotH;
dot.setAttribute('cx', cx.toFixed(2));
dot.setAttribute('cy', cy.toFixed(2));
cursor.setAttribute('opacity', '1');
- const windowStart = new Date(point.time - (UPTIME_HISTORY_WINDOW_DAYS - 1) * UPTIME_HISTORY_ONE_DAY_MS);
+ const windowStart = new Date(point.time - (windowDays - 1) * UPTIME_HISTORY_ONE_DAY_MS);
+ const scope =
+ opts.tooltipScope ||
+ (windowDays === 1
+ ? 'uptime on this day (UTC)'
+ : `over the ${windowDays} days from ${formatUptimeHistoryDate(windowStart)}`);
tooltip.innerHTML =
`
${formatUptimeHistoryDate(point.time)}
` +
`
${point.uptime.toFixed(2)}%` +
- `over the 90 days from ${formatUptimeHistoryDate(windowStart)}
` +
+ `
${scope}` +
`
Click to list these incidents
`;
tooltip.classList.add('active');
tooltip.setAttribute('aria-hidden', 'false');
@@ -425,7 +720,7 @@ const attachUptimeHistoryInteraction = (container, series, geometry) => {
svgEl.addEventListener('pointerdown', onMove);
svgEl.addEventListener('pointerleave', hide);
svgEl.addEventListener('click', () => {
- if (active >= 0) focusTimelineOn(series[active]);
+ if (active >= 0) focusTimelineOn(series[active], windowDays);
});
// Replacing innerHTML discards the old SVG along with its listeners, but the
@@ -438,6 +733,9 @@ const attachUptimeHistoryInteraction = (container, series, geometry) => {
lastIndex: () => series.length - 1,
activeIndex: () => active,
pointAt: (index) => series[index],
+ // The container-level keydown handler is bound once but must use the window of
+ // whichever render published this controller last.
+ windowDays,
};
container.tabIndex = 0;
@@ -460,7 +758,7 @@ const attachUptimeHistoryInteraction = (container, series, geometry) => {
c.show(Math.min(c.lastIndex(), Math.max(0, next)));
} else if (event.key === 'Enter' && current >= 0) {
event.preventDefault();
- focusTimelineOn(c.pointAt(current));
+ focusTimelineOn(c.pointAt(current), c.windowDays);
} else if (event.key === 'Escape') {
c.hide();
}
@@ -470,20 +768,64 @@ const attachUptimeHistoryInteraction = (container, series, geometry) => {
const renderUptimeHistoryChart = (windowEntries, rangeEnd, options = {}) => {
const projectStartMs = options.projectStartUTC ?? PROJECT_START_UTC;
const endMs = rangeEnd instanceof Date ? rangeEnd.getTime() : Number(rangeEnd);
+ const windowDays = options.windowDays || UPTIME_HISTORY_WINDOW_DAYS;
+ const daily = windowDays === 1;
const intervals = collectUptimeHistoryIntervals(windowEntries);
- const series = computeUptimeHistorySeries(intervals, projectStartMs, endMs);
- const lifetimeUptime = computeLifetimeUptime(intervals, projectStartMs, endMs);
+ const workWindow = daily ? options.workWindow || null : null;
+ const series = workWindow
+ ? computeWorkHoursDailySeries(intervals, projectStartMs, endMs, workWindow)
+ : computeUptimeHistorySeries(
+ intervals,
+ projectStartMs,
+ endMs,
+ windowDays * UPTIME_HISTORY_ONE_DAY_MS,
+ );
+
+ // With a work-hours filter the lifetime figure must honor it too: total downtime
+ // inside the filtered windows over total filtered time, not calendar time.
+ let lifetimeUptime;
+ if (workWindow) {
+ let downtimeMs = 0;
+ let totalMs = 0;
+ series.forEach((point) => {
+ downtimeMs += point.downtimeMs;
+ totalMs += point.windowMs;
+ });
+ lifetimeUptime = totalMs > 0 ? Math.max(0, 1 - downtimeMs / totalMs) * 100 : 100;
+ } else {
+ lifetimeUptime = computeLifetimeUptime(intervals, projectStartMs, endMs);
+ }
const captionSelector = options.captionTarget || '#uptimeHistoryCaption';
const chartSelector = options.chartTarget || '#uptimeHistoryImage';
+ const svgOpts = {
+ ariaLabel:
+ options.ariaLabel ||
+ (daily
+ ? 'GitHub Platform daily uptime since project start'
+ : 'GitHub Platform 90-day rolling uptime since project start'),
+ showPeak: !daily,
+ lowLabel: daily ? 'worst day' : 'low',
+ gradientId: daily ? 'uptimeDaily' : 'uptimeHistory',
+ // Only the daily view offers the axis control; the rolling view already spans a
+ // few percent, where a log axis is indistinguishable from a linear one.
+ yScaleMode: daily && UPTIME_Y_SCALE_MODES.includes(options.yScaleMode)
+ ? options.yScaleMode
+ : 'linear',
+ };
+
// Only the methodology is left. The start date is already on the x-axis as
// "start: Jun 11, 2022", the lifetime figure is in the stat strip, and the hover
// hint is redundant with the crosshair cursor and the container's aria-label.
const caption = document.querySelector(captionSelector);
if (caption) {
- caption.textContent = '90-day rolling window · non-maintenance downtime, merged windows';
+ caption.textContent =
+ options.caption ||
+ (daily
+ ? 'uptime per UTC day · non-maintenance downtime, merged windows'
+ : '90-day rolling window · non-maintenance downtime, merged windows');
}
const chartContainer = document.querySelector(chartSelector);
@@ -508,10 +850,13 @@ const renderUptimeHistoryChart = (windowEntries, rangeEnd, options = {}) => {
const draw = () => {
const width = contentWidth();
const layout = options.layout || layoutForWidth(width);
- const { markup, geometry } = buildUptimeHistorySVG(series, layout);
+ const { markup, geometry } = buildUptimeHistorySVG(series, layout, svgOpts);
chartContainer.innerHTML = markup;
drawnWidth = layout.width;
- attachUptimeHistoryInteraction(chartContainer, series, geometry);
+ attachUptimeHistoryInteraction(chartContainer, series, geometry, {
+ windowDays,
+ tooltipScope: options.tooltipScope,
+ });
// A draw can land while the container measures zero (hidden panel, mid-layout
// reflow) and fall back to the default width. Nothing resizes afterwards, so the
@@ -560,9 +905,14 @@ const renderUptimeHistoryChart = (windowEntries, rangeEnd, options = {}) => {
var UptimeHistoryChart = {
render: renderUptimeHistoryChart,
computeUptimeHistorySeries,
+ computeWorkHoursDailySeries,
computeLifetimeUptime,
collectDowntimeIntervals: collectUptimeHistoryIntervals,
buildSVG: buildUptimeHistorySVG,
+ // The share card redraws the same series on a canvas; it takes the axis from here so
+ // the PNG cannot disagree with the page about where a point sits.
+ makeYScale: makeUptimeYScale,
+ Y_SCALE_MODES: UPTIME_Y_SCALE_MODES,
formatUTCDate: formatUptimeHistoryDate,
PROJECT_START_UTC,
WINDOW_DAYS: UPTIME_HISTORY_WINDOW_DAYS,