Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ Every one of these has already cost someone an hour:
| Poll interval stays at 30 min; 15 min was measured and is marginal | `notes/polling.md` |
| `startTime` is immutable and back-dated, so durations measure the outage | `notes/grading.md` § Does startTime drift |
| Every measured window ends at the collection horizon, not at the build clock | `notes/grading.md` § What the clock knows |
| A month listed before any of its data exists says **no data yet**, never zeros: decided once by `model.month_watched` and shipped as `D.nodata`, so the county page and the app cannot disagree; nothing is counted or filed in it. It stays listed, because the month list follows the calendar | `notes/design-alignment.md` § A month the data has not reached says "no data yet" (2026-09-24) |
| Stored and computed in UTC, shown in Dublin time: months, day cells and gate dates are cut at Dublin midnight (as UTC instants, never Dublin-zoned datetimes), and case records stay UTC with only what is printed converted (`render._local`, `local()` in site.html) | `notes/grading.md` § Months and days are Dublin's (2026-09-24) |
| An outage still listed at the last poll is not judged on the charter, and a live fault runs to its last sighting, not to a passed estimate (planned works keep their schedule, issue #49) | `notes/grading.md` § An outage still listed; § A live fault runs to its last sighting (2026-09-24) |
| Peak customers means the most off while the outage was live | `notes/grading.md` § The peak is the highest count |
Expand Down
14 changes: 8 additions & 6 deletions esb_site/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
DEFAULT_OUT = "out/site"


def parse_now(value):
"""UTC when it carries no offset, converted when it does."""
when = datetime.fromisoformat(value)
return when.replace(tzinfo=UTC) if when.tzinfo is None else when.astimezone(UTC)


def main(argv=None) -> int:
parser = argparse.ArgumentParser(
prog="esb_site", description="Build the static ESB outage status site."
Expand All @@ -25,7 +31,7 @@ def main(argv=None) -> int:
parser.add_argument(
"--now",
default=None,
help="override the build clock, as an ISO UTC timestamp (for reproducible builds)",
help="override the build clock, as an ISO timestamp, UTC unless it carries an offset",
)
args = parser.parse_args(argv)

Expand All @@ -38,11 +44,7 @@ def main(argv=None) -> int:
)
return 1

now = (
datetime.fromisoformat(args.now).replace(tzinfo=UTC)
if args.now
else datetime.now(UTC)
)
now = parse_now(args.now) if args.now else datetime.now(UTC)

sa_index = model.SmallAreaIndex.load()
outages, unplaced, until = model.load_outages(db_path, sa_index, now)
Expand Down
25 changes: 23 additions & 2 deletions esb_site/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -924,7 +924,13 @@ def load_outages(db_path, sa_index, now):
segments=segments,
)
)
return label_repeats(merge_events(outages)), unplaced, until
# The record starts at the first poll: an event over before it
# overlaps no observed window, so nothing the site derives may count
# it, a repeat chain included. Dropped by the end the merge settles on
# (a sibling lingering a poll past a confirmed restore is the feed
# catching up) and before the chains are labelled.
events = [o for o in merge_events(outages) if o.end > COLLECTION_START]
return label_repeats(events), unplaced, until
finally:
conn.close()

Expand Down Expand Up @@ -956,6 +962,21 @@ def observed_window(ym, until):
return max(lo, COLLECTION_START), min(hi, until)


def overlaps(o, lo, hi):
"""Whether outage `o` falls in the window [lo, hi). An end published past
the horizon passes a bare overlap test against the inverted window of a
month the data has not reached, so an empty window takes nothing."""
return hi > lo and o.end > lo and o.start < hi


def month_watched(ym, until):
"""Whether the collected data reaches into month `ym` at all. A build just
after the 1st, or one while the collector is down, lists a month before
any data for it exists, and its window is then empty or inverted."""
lo, hi = observed_window(ym, until)
return hi > lo


def days_gate(ym, until):
"""When month `ym` reaches MIN_GRADED_DAYS, or None once it has.

Expand Down Expand Up @@ -994,7 +1015,7 @@ def county_month(outages, county, customers, ym, now, until):
for o in outages:
if o.county != county or not o.start or not o.end:
continue
if o.end <= lo or o.start >= hi:
if not overlaps(o, lo, hi):
continue
cm = o.customer_minutes(lo, hi)
if o.planned:
Expand Down
52 changes: 36 additions & 16 deletions esb_site/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from pathlib import Path
from urllib.parse import quote

import statusui

Expand Down Expand Up @@ -123,13 +124,9 @@ def build(outages, sa_index, now, until):
"""
months = model.month_list(model.COLLECTION_START, now)

# A county's record starts at the first poll: an outage restored before
# it overlaps no observed window, so the page never lists it and nothing
# derived from the county's list may count it either.
by_county = defaultdict(list)
for o in outages:
if o.end > model.COLLECTION_START:
by_county[o.county].append(o)
by_county[o.county].append(o)

stats, national = {}, {}
for county in sa_index.counties:
Expand All @@ -156,7 +153,7 @@ def build(outages, sa_index, now, until):

for ym in months:
lo, hi = model.observed_window(ym, until)
live = [o for o in outages if o.start and o.end and o.end > lo and o.start < hi]
live = [o for o in outages if o.start and o.end and model.overlaps(o, lo, hi)]
faults = [o for o in live if not o.planned]
# Same gate as county_month: an outage still out has no restoration to
# judge, and its elapsed time would score as a fast one.
Expand Down Expand Up @@ -226,8 +223,10 @@ def entries(county, names):
# reader's clock rather than the build's. STALE_AFTER travels with it,
# so a page served from cache can still go stale.
"observed_iso": f"{until:%Y-%m-%dT%H:%M:00Z}",
# the Dublin month the horizon falls in, for the "so far" wording
"observed_month": f"{model.local(until):%Y-%m}",
# the Dublin month the data last reaches, for the "so far" wording;
# a horizon on the stroke of midnight watched none of the new month
"observed_month": f"{model.local(until - timedelta(microseconds=1)):%Y-%m}",
"nodata": [ym for ym in months if not model.month_watched(ym, until)],
"stale_hours": round(STALE_AFTER.total_seconds() / 3600),
# Two dates at most, and the same for every county, so they sit here
# rather than on every month of every county's row.
Expand Down Expand Up @@ -305,7 +304,7 @@ def shard(outages, months, until):
for o in sorted(outages, key=lambda o: o.start, reverse=True):
record = None
for ym, lo, hi in windows:
if o.end > lo and o.start < hi:
if model.overlaps(o, lo, hi):
record = case_record(o) if record is None else record
by_month[ym].append(record)
return by_month
Expand Down Expand Up @@ -452,7 +451,7 @@ def _update_line(row, key, planned=False):
bits.append(f"<b>{label}</b>")
if customers is not None:
bits.append(
f"{customers:,} customers"
f"{customers:,} customer{'' if customers == 1 else 's'}"
+ (" still off" if kind == "update" else "")
)
cls = ' class="key"' if key else ""
Expand Down Expand Up @@ -500,7 +499,9 @@ def _daygate(months, until):

Absent means graded on days; "" means a month that can never reach five.
"""
gates = ((ym, model.days_gate(ym, until)) for ym in months)
gates = (
(ym, model.days_gate(ym, until)) for ym in months if model.month_watched(ym, until)
)
return {
ym: "" if when >= model.month_bounds(ym)[1] else f"{model.local(when):%-d %B}"
for ym, when in gates
Expand All @@ -514,6 +515,8 @@ def ungraded_reason(ym, faults, until):
Three gates withhold it and naming the wrong one sends a reader after
outages that are not the reason. Mirrored in site.html (ungradedReason).
"""
if not model.month_watched(ym, until):
return f"There is no data yet for {month_label(ym)}"
when = model.days_gate(ym, until)
if when is not None:
# past the month's end: it can never reach five days, so promise no date
Expand Down Expand Up @@ -580,6 +583,8 @@ def _month_watched(ym, until):
months are short, and a row of zeros for three hours of July reads as a
quiet month rather than an absent collector.
"""
if not model.month_watched(ym, until):
return "no data yet"
lo, hi = model.month_bounds(ym)
olo, ohi = model.observed_window(ym, until)
bits = []
Expand Down Expand Up @@ -620,6 +625,13 @@ def _county_months_html(county, data, months, until):
for ym in reversed(months):
m = data["stats"][county][ym]
watched = _month_watched(ym, until)
if not model.month_watched(ym, until):
rows.append(
f'<tr><th scope="row">{month_label(ym)}<span class="part">{watched}</span></th>'
f"<td>{_grade_chip(None, reason=ungraded_reason(ym, 0, until))}</td>"
+ "<td>–</td>" * 7 + "</tr>"
)
continue
rows.append(
f'<tr><th scope="row">{month_label(ym)}'
+ (f'<span class="part">{watched}</span>' if watched else "")
Expand Down Expand Up @@ -764,7 +776,7 @@ def _spots_html(spots, since):
f"<li>{html.escape(loc)}"
'<span class="fill"></span>'
f'<span class="n">{n} faults</span>'
f'<span class="p">up to {peak:,} customers</span></li>'
f'<span class="p">up to {peak:,} customer{"" if peak == 1 else "s"}</span></li>'
for loc, n, peak in spots
)
return (
Expand All @@ -777,6 +789,14 @@ def _spots_html(spots, since):
)


def _cell(text):
"""A text cell, defused: one opening with a formula character, even
behind spaces a spreadsheet trims, runs as a formula when opened."""
if text and (text[0] in "\t\r" or text.lstrip()[:1] in ("=", "+", "-", "@")):
return "'" + text
return text


def county_csv(outages):
"""One county's merged events as CSV, oldest first; the columns are
CSV_COLUMNS. The site's own rows, so a reader gets what the page counts
Expand All @@ -786,8 +806,8 @@ def county_csv(outages):
out.writerow(CSV_COLUMNS)
for o in sorted(outages, key=lambda o: (o.start, int(o.id))):
out.writerow([
" ".join(o.ids), o.county, o.town, o.esb_location,
"planned" if o.planned else "fault", model.reason_label(o.reason),
" ".join(o.ids), o.county, _cell(o.town), _cell(o.esb_location),
"planned" if o.planned else "fault", _cell(model.reason_label(o.reason)),
o.customers, model.fmt_utc(o.start), model.fmt_utc(o.end), o.end_src,
model.fmt_utc(o.est), model.fmt_utc(o.first_est), int(o.ongoing),
round(o.customer_minutes(o.start, o.end)),
Expand Down Expand Up @@ -917,7 +937,7 @@ def area_page(county, name, pop, events, nearby, data):
faults = sum(1 for o in events if not o.planned)
planned = len(events) - faults
near = "".join(
f'<li><a href="../{slug(c)}/{slug(n)}.html">{html.escape(n)}</a>'
f'<li><a href="../../{area_path(c, n)}">{html.escape(n)}</a>'
'<span class="fill"></span>'
f'<span class="n">{_km_label(d)}</span>'
f'<span class="p">{"" if c == county else f"County {html.escape(c)}"}</span></li>'
Expand Down Expand Up @@ -952,7 +972,7 @@ def area_page(county, name, pop, events, nearby, data):
'<div class="card"><h2>Elsewhere</h2><p class="nav">'
f'<a href="../../c/{slug(county)}.html">County {html.escape(county)}’s '
"whole record</a> "
f'<a href="../../index.html#county/{county}">County&nbsp;'
f'<a href="../../index.html#county/{html.escape(quote(county))}">County&nbsp;'
f"{html.escape(county)}’s interactive view</a></p></div>"
)
# the record first, what the page holds last - truncation must not turn
Expand Down
39 changes: 27 additions & 12 deletions esb_site/site.html
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ <h3 class="natheading" id="natHeading"></h3>
// counties' fault counts and so can use none of them. "" once the month has
// five days.
function dayGateReason(ym) {
if (noDataYet(ym)) return "There is no data yet for " + monthLabelLong(ym);
var from = D.daygate && D.daygate[ym];
if (from === undefined) return "";
if (!from) return "Only part of " + monthLabelLong(ym) + " was watched, so it is not graded";
Expand Down Expand Up @@ -328,15 +329,16 @@ <h3 class="natheading" id="natHeading"></h3>
});
rows.sort(function (a, b) { return a.name.localeCompare(b.name); });

var none = noDataYet(curMonth);
document.getElementById("list").innerHTML = rows.map(function (r) {
var m = r.m, g = m ? m[1] : null;
return '<div class="place"><div class="row" onclick="go(\'' + esc(r.name) + '\')">' +
return '<div class="place"><div class="row" data-county="' + esc(r.name) + '">' +
'<div class="cname">' + gradeChip(g, m ? m[4] : 0) +
'<a href="c/' + slug(r.name) + '.html" onclick="event.preventDefault()">' + esc(r.name) + "</a></div>" +
'<div class="bar">' + bar(m ? m[0] : "", curMonth) + "</div>" +
'<div class="stats"><div class="counts">' +
"<b>" + (m ? m[4] : 0) + "</b><span>faults</span>" +
"<b>" + (m ? m[5] : 0) + "</b><span>planned</span></div>" +
"<b>" + (none ? "–" : m ? m[4] : 0) + "</b><span>faults</span>" +
"<b>" + (none ? "–" : m ? m[5] : 0) + "</b><span>planned</span></div>" +
'<div class="cml"><b>' + (m && m[2] != null ? m[2] + "%" : "–") +
"</b><br>restored in 4h</div>" +
'</div><span class="chev">›</span>' +
Expand All @@ -345,12 +347,14 @@ <h3 class="natheading" id="natHeading"></h3>

var n = D.national[curMonth] || [0, 0, 0, 0, 0, null, null, 0];
var within = n[5];
// "so far" only while the viewed month is still collecting
var partial = D.observed_month === curMonth;
// "so far" only while the viewed month is still collecting: the newest
// listed month, and one the data has reached
var partial = curMonth === D.months[D.months.length - 1] && !noDataYet(curMonth) &&
D.observed_month === curMonth;
var ungraded = ungradedNote(rows, curMonth);
document.getElementById("natline").innerHTML =
"<strong>" + monthLabelLong(curMonth) + (partial ? " so far" : "") + ":</strong> " +
plural(n[1], "fault") + " and " + plural(n[2], "planned outage");
(none ? "no data yet" : plural(n[1], "fault") + " and " + plural(n[2], "planned outage"));

document.getElementById("basis").innerHTML =
"Counties are graded on ESB Networks' own published aim: <b>supply restored within " +
Expand All @@ -377,13 +381,16 @@ <h3 class="natheading" id="natHeading"></h3>
// the footer, and it already means faults alone.
[fmtCml(n[0]), "customer minutes lost"]
].map(function (t) {
return '<div class="tile"><div class="v">' + esc(t[0]) + '</div><div class="k">' + esc(t[1]) + "</div></div>";
return '<div class="tile"><div class="v">' + esc(none ? "–" : t[0]) + '</div><div class="k">' + esc(t[1]) + "</div></div>";
}).join("");

document.getElementById("legend").innerHTML = legendHtml();
}

/* --- county -------------------------------------------------------------- */
// A data.js cached from before `nodata` existed falls back to the month order.
function noDataYet(ym) { return D.nodata ? D.nodata.indexOf(ym) >= 0 : ym > D.observed_month; }

function renderCounty() {
var el = document.getElementById("countyview"), c = curCounty;
var m = (D.stats[c] || {})[curMonth];
Expand Down Expand Up @@ -418,15 +425,16 @@ <h3 class="natheading" id="natHeading"></h3>
[m ? m[5] : 0, "planned outages"],
[m ? num(m[6]) : 0, "customers hit by faults"]
].map(function (t) {
return '<div class="tile"><div class="v">' + esc(t[0]) + '</div><div class="k">' + esc(t[1]) + "</div></div>";
return '<div class="tile"><div class="v">' + esc(noDataYet(curMonth) ? "–" : t[0]) + '</div><div class="k">' + esc(t[1]) + "</div></div>";
}).join("") + "</div></div>";

var body;
if (loading) body = '<div class="card"><div class="empty">Loading outages…</div></div>';
else if (failed) body = '<div class="card"><div class="empty">Could not load the outages for ' +
esc(c) + ". Try reloading the page.</div></div>";
else if (!cases.length) body = '<div class="card"><div class="empty">No outages recorded in ' +
esc(c) + " in " + monthLabel(curMonth) + ".</div></div>";
else if (!cases.length) body = '<div class="card"><div class="empty">' + (noDataYet(curMonth)
? "No data yet for " + monthLabelLong(curMonth)
: "No outages recorded in " + esc(c) + " in " + monthLabel(curMonth)) + ".</div></div>";
else body = '<div class="card">' + cases.map(caseHtml).join("") + "</div>";

el.innerHTML = head + summary + body;
Expand Down Expand Up @@ -469,7 +477,7 @@ <h3 class="natheading" id="natHeading"></h3>
var bits = [];
var label = (planned && PLANNED_ROW_LABEL[r[0]]) || ROW_LABEL[r[0]];
if (label) bits.push("<b>" + label + "</b>");
if (r[2] != null) bits.push(num(r[2]) + " customers" + (r[0] === "update" ? " still off" : ""));
if (r[2] != null) bits.push(plural(r[2], "customer") + (r[0] === "update" ? " still off" : ""));
return '<li class="' + (key ? "key" : "") + '"><time>' + when(local(r[1])) + "</time>" + bits.join(" · ") + "</li>";
}

Expand Down Expand Up @@ -508,7 +516,8 @@ <h3 class="natheading" id="natHeading"></h3>

function route() {
var m = location.hash.match(/^#county\/(.+)$/);
curCounty = m ? decodeURIComponent(m[1]) : null;
// a hand-edited or truncated link falls back to the overview, not a throw
try { curCounty = m ? decodeURIComponent(m[1]) : null; } catch (e) { curCounty = null; }
if (curCounty && !D.stats[curCounty]) curCounty = null;
if (curCounty) loadCounty(curCounty, render);
render();
Expand Down Expand Up @@ -570,6 +579,12 @@ <h3 class="natheading" id="natHeading"></h3>
bindDayCaption();
bindMonthReveal();
addEventListener("hashchange", route);
// The name travels in an attribute, not in an inline handler's JS string,
// where HTML escaping is undone before the script sees it.
document.getElementById("list").addEventListener("click", function (e) {
var row = e.target.closest(".row[data-county]");
if (row) go(row.getAttribute("data-county"));
});
route();
}
boot();
Expand Down
Loading
Loading