From 057b5b2da3f644d335c6996ee3c78e1023a798fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 11:56:50 +0000 Subject: [PATCH 01/12] Count outages over before the first poll nowhere, not only on county pages build dropped outages restored before collection began from each county's list, as the county page, shard and CSV require, but area_index and the search index were built from the unfiltered list. areas.html, the area pages, the county page's area card and search.js counted 14 outages the county page did not list, and an area whose only event was one of them got a page and a sitemap entry. One helper now applies the rule everywhere. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- esb_site/render.py | 16 ++++++++++------ tests/test_site_areas.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/esb_site/render.py b/esb_site/render.py index 7a1581d..5e08d9b 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -115,6 +115,13 @@ def _approx(n): return int(statusui.half_up(n / step)) * step +def since_collection(outages): + """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 + else the site derives may count it either.""" + return [o for o in outages if o.end > model.COLLECTION_START] + + def build(outages, sa_index, now, until): """Assemble every value the templates need, and nothing they do not. @@ -122,14 +129,11 @@ def build(outages, sa_index, now, until): data stops, and every measured window ends there. """ months = model.month_list(model.COLLECTION_START, now) + outages = since_collection(outages) - # 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: @@ -809,7 +813,7 @@ def area_index(outages, sa_index): first. Grouped on the census assignment, never ESB's location string, which fragments (uisce measured 3,866 distinct values in its feed's).""" by_area = defaultdict(list) - for o in outages: + for o in since_collection(outages): by_area[(o.county, o.town_code)].append(o) by_county = defaultdict(list) for (county, code), events in by_area.items(): diff --git a/tests/test_site_areas.py b/tests/test_site_areas.py index 46a5d5e..367da18 100644 --- a/tests/test_site_areas.py +++ b/tests/test_site_areas.py @@ -31,6 +31,23 @@ SLIGO_TOWN = {"c": "54.2697,-8.4771"} # Sligo, "Sligo": the town named for its county +class TestBeforeCollection(SiteModelCase): + def test_an_outage_over_before_the_first_poll_is_counted_nowhere(self): + # restored at 18:00 on 31 July and still in the feed at the first poll + self.observe( + detail("1", location="Rathfarnham", outageType="Restored", + startTime="31/07/2026 15:00", restoreTime="31/07/2026 18:00"), + datetime(2026, 7, 31, 21, 5, tzinfo=UTC), + ) + now = datetime(2026, 8, 20, tzinfo=UTC) + self.poll(now) + outages, _, index = self.load(now) + self.assertEqual(len(outages), 1) + self.assertEqual(render.area_index(outages, index), []) + search = render.build(outages, index, now, self.until)[3] + self.assertNotIn("Rathfarnham", json.dumps(search)) + + class TestWhichAreasGetOne(unittest.TestCase): def test_a_named_place_gets_a_page(self): for code in ("19848", "01626", "02341-Cabra-Glasnevin", "04345"): From ec61dc6d6a602180c6c0469d525545026ac302a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 11:57:19 +0000 Subject: [PATCH 02/12] Defuse formula characters in the published CSV county_csv wrote ESB's free-text location, the Census town and the planned reason as-is, so a cell opening with =, +, -, @, tab or carriage return ran as a formula in the spreadsheet a reader opened c/.csv in. Those cells now get the usual leading apostrophe. No location in the corpus starts with one today. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- esb_site/render.py | 10 ++++++++-- tests/test_site_county.py | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/esb_site/render.py b/esb_site/render.py index 5e08d9b..e02b0b8 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -781,6 +781,12 @@ def _spots_html(spots, since): ) +def _cell(text): + """ESB's free text, defused: a cell opening with a formula character is + run as a formula by the spreadsheet a reader opens the file in.""" + return "'" + text if text and text[0] in "=+-@\t\r" else 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 @@ -790,8 +796,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)), diff --git a/tests/test_site_county.py b/tests/test_site_county.py index bd169c1..aebfcbd 100644 --- a/tests/test_site_county.py +++ b/tests/test_site_county.py @@ -359,6 +359,13 @@ def test_one_row_per_merged_event_with_every_id(self): self.assertEqual(rows[1]["location"], "Marino") self.assertEqual(tuple(rows[0]), render.CSV_COLUMNS) + def test_a_location_that_reads_as_a_formula_is_published_as_text(self): + self.observe(detail("1", location='=HYPERLINK("http://x","Ballina")'), + datetime(2026, 8, 10, 10, 0, tzinfo=UTC)) + outages, _, _ = self.load(datetime(2026, 8, 20, tzinfo=UTC)) + row = next(csv.DictReader(io.StringIO(render.county_csv(outages)))) + self.assertEqual(row["location"], '\'=HYPERLINK("http://x","Ballina")') + def test_the_page_links_its_csv(self): self.observe(detail("1"), datetime(2026, 8, 10, 10, 0, tzinfo=UTC)) self.poll(datetime(2026, 9, 1, tzinfo=UTC), n_listed=1) From 28d79ccbdb5d8c0f37a674b909f21a1d0e9e8021 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 11:57:36 +0000 Subject: [PATCH 03/12] Convert a --now offset instead of dropping it --now was parsed with .replace(tzinfo=UTC), which relabels a timestamp carrying an offset rather than converting it: 10:00+01:00 built as 10:00 UTC, an hour late, enough to move a day cell or a month boundary. It is now converted, and a bare timestamp is still read as UTC. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- esb_site/__main__.py | 14 ++++++++------ tests/test_site_model.py | 10 ++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/esb_site/__main__.py b/esb_site/__main__.py index 4974c89..3bc07cd 100644 --- a/esb_site/__main__.py +++ b/esb_site/__main__.py @@ -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." @@ -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) @@ -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) diff --git a/tests/test_site_model.py b/tests/test_site_model.py index 500157e..ee58bbc 100644 --- a/tests/test_site_model.py +++ b/tests/test_site_model.py @@ -1578,3 +1578,13 @@ def body(name): self.assertIn("when(local(r[1]))", body("updateLine")) self.assertIn('timeZone: "Europe/Dublin"', page) self.assertIn("D.observed_month === curMonth", page) + + +class TestTheBuildClock(unittest.TestCase): + def test_an_offset_is_converted_not_dropped(self): + from esb_site.__main__ import parse_now + + expect = datetime(2026, 9, 24, 9, 0, tzinfo=UTC) + self.assertEqual(parse_now("2026-09-24T10:00:00+01:00"), expect) + self.assertEqual(parse_now("2026-09-24T09:00:00Z"), expect) + self.assertEqual(parse_now("2026-09-24T09:00:00"), expect) From 3ef57c6b41c83df58cca6bb0d65cf701c8efd981 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 11:58:59 +0000 Subject: [PATCH 04/12] Stop splicing a county name into an inline handler, and a bad hash throwing The county row's onclick put the name inside a JS string with esc(), which the HTML parser undoes before the script runs, so an apostrophe in a name broke out of the string; ui.js had already dropped inline handlers for this reason. The name now travels in data-county to one delegated listener. And route() decoded the hash unguarded: #county/%E0 threw URIError in boot and the list never rendered (reproduced in Chromium: 0 rows). It now falls back to the overview. Checked in Chromium: a row click still opens the county, the bad hash shows all 26, no page errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- esb_site/site.html | 11 +++++++++-- tests/test_site_model.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/esb_site/site.html b/esb_site/site.html index 1d98393..43fc519 100644 --- a/esb_site/site.html +++ b/esb_site/site.html @@ -330,7 +330,7 @@

document.getElementById("list").innerHTML = rows.map(function (r) { var m = r.m, g = m ? m[1] : null; - return '
' + + return '
' + '
' + gradeChip(g, m ? m[4] : 0) + '' + esc(r.name) + "
" + '
' + bar(m ? m[0] : "", curMonth) + "
" + @@ -508,7 +508,8 @@

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(); @@ -570,6 +571,12 @@

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(); diff --git a/tests/test_site_model.py b/tests/test_site_model.py index ee58bbc..9e0c07d 100644 --- a/tests/test_site_model.py +++ b/tests/test_site_model.py @@ -1588,3 +1588,17 @@ def test_an_offset_is_converted_not_dropped(self): self.assertEqual(parse_now("2026-09-24T10:00:00+01:00"), expect) self.assertEqual(parse_now("2026-09-24T09:00:00Z"), expect) self.assertEqual(parse_now("2026-09-24T09:00:00"), expect) + + +class TestTheAppScript(unittest.TestCase): + """CI runs no JS, so what matters about these call sites is held as text.""" + + page = (Path(model.__file__).parent / "site.html").read_text() + + def test_no_name_is_spliced_into_an_inline_handler(self): + self.assertNotIn("onclick=\"go(\\''", self.page) + self.assertIn('data-county="\' + esc(r.name)', self.page) + + def test_a_malformed_link_cannot_throw_out_of_route(self): + route = self.page.split("function route(", 1)[1].split("\nfunction ", 1)[0] + self.assertIn("try { curCounty = m ? decodeURIComponent(m[1])", route) From 96645e0a15f42cab34d8aebdda6197922df229e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 11:59:31 +0000 Subject: [PATCH 05/12] Say "1 customer still off", not "1 customers" The summary line already handled the singular; the timeline row, in render.py and its site.html mirror, always pluralised. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- esb_site/render.py | 2 +- esb_site/site.html | 2 +- tests/test_site_model.py | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/esb_site/render.py b/esb_site/render.py index e02b0b8..54b18f8 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -456,7 +456,7 @@ def _update_line(row, key, planned=False): bits.append(f"{label}") 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 "" diff --git a/esb_site/site.html b/esb_site/site.html index 43fc519..22ffa7b 100644 --- a/esb_site/site.html +++ b/esb_site/site.html @@ -469,7 +469,7 @@

var bits = []; var label = (planned && PLANNED_ROW_LABEL[r[0]]) || ROW_LABEL[r[0]]; if (label) bits.push("" + label + ""); - 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 '
  • " + bits.join(" · ") + "
  • "; } diff --git a/tests/test_site_model.py b/tests/test_site_model.py index 9e0c07d..10ec608 100644 --- a/tests/test_site_model.py +++ b/tests/test_site_model.py @@ -1556,6 +1556,10 @@ def test_a_timeline_row_prints_dublins_time(self): line = render._update_line(["update", "2026-08-24T14:15", 40], False) self.assertIn("", line) + def test_one_customer_still_off_is_singular(self): + line = render._update_line(["update", "2026-08-24T14:15", 1], False) + self.assertIn("1 customer still off", line) + def test_the_caveat_names_dublins_day(self): until = datetime(2026, 9, 9, 23, 30, tzinfo=UTC) self.assertEqual(render._month_watched("2026-09", until), "to 10 Sep") From e52f44e7a26735fd47bb0686a37b9c999408e5e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 12:00:00 +0000 Subject: [PATCH 06/12] Build the nearby-area links from area_path The nearby card rebuilt the a//.html scheme by hand, so a change to area_path would have moved the pages and quietly broken every nearby link. Same target, one definition. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- esb_site/render.py | 2 +- tests/test_site_areas.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esb_site/render.py b/esb_site/render.py index 54b18f8..37160d8 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -927,7 +927,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'
  • {html.escape(n)}' + f'
  • {html.escape(n)}' '' f'{_km_label(d)}' f'{"" if c == county else f"County {html.escape(c)}"}
  • ' diff --git a/tests/test_site_areas.py b/tests/test_site_areas.py index 367da18..f0d009a 100644 --- a/tests/test_site_areas.py +++ b/tests/test_site_areas.py @@ -294,7 +294,7 @@ def test_it_points_at_its_nearest_neighbours_with_distances(self): """The disclaimer made actionable: the reader's one-click check of where else their outage may have been filed.""" page = self.page("a/dublin/skerries.html") - self.assertIn('href="../dublin/cabra-glasnevin.html"', page) + self.assertIn('href="../../a/dublin/cabra-glasnevin.html"', page) self.assertRegex(page, r'\d+ km') def test_the_description_is_the_record_first(self): From d99d2ff782ff1703e1be7ba8ff23783a8cc952ac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 12:05:18 +0000 Subject: [PATCH 07/12] Drop events over before the first poll in load_outages, before the chains The render-level filter this PR added ran after label_repeats, so a fault following a pre-collection outage at the same spot kept a chain naming a leg the site lists nowhere, and every other caller of load_outages, the build summary and its empty-database guard included, still saw those events. They are now dropped once, in load_outages, after the merge (an event ends with its last member) and before the chains are labelled; the render helper goes. Found by review of this PR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- esb_site/model.py | 7 ++++++- esb_site/render.py | 10 +--------- tests/test_site_areas.py | 16 ++++++++++++---- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/esb_site/model.py b/esb_site/model.py index fb91c1e..9b638ed 100644 --- a/esb_site/model.py +++ b/esb_site/model.py @@ -924,7 +924,12 @@ 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 after the merge, whose event + # ends with its last member, 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() diff --git a/esb_site/render.py b/esb_site/render.py index 37160d8..73d6731 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -115,13 +115,6 @@ def _approx(n): return int(statusui.half_up(n / step)) * step -def since_collection(outages): - """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 - else the site derives may count it either.""" - return [o for o in outages if o.end > model.COLLECTION_START] - - def build(outages, sa_index, now, until): """Assemble every value the templates need, and nothing they do not. @@ -129,7 +122,6 @@ def build(outages, sa_index, now, until): data stops, and every measured window ends there. """ months = model.month_list(model.COLLECTION_START, now) - outages = since_collection(outages) by_county = defaultdict(list) for o in outages: @@ -819,7 +811,7 @@ def area_index(outages, sa_index): first. Grouped on the census assignment, never ESB's location string, which fragments (uisce measured 3,866 distinct values in its feed's).""" by_area = defaultdict(list) - for o in since_collection(outages): + for o in outages: by_area[(o.county, o.town_code)].append(o) by_county = defaultdict(list) for (county, code), events in by_area.items(): diff --git a/tests/test_site_areas.py b/tests/test_site_areas.py index f0d009a..19af48b 100644 --- a/tests/test_site_areas.py +++ b/tests/test_site_areas.py @@ -39,13 +39,21 @@ def test_an_outage_over_before_the_first_poll_is_counted_nowhere(self): startTime="31/07/2026 15:00", restoreTime="31/07/2026 18:00"), datetime(2026, 7, 31, 21, 5, tzinfo=UTC), ) + # and a repeat at the same spot after it, which must not read as leg 2 + self.observe( + detail("2", location="Rathfarnham", outageType="Restored", + startTime="31/07/2026 18:05", restoreTime="31/07/2026 23:00"), + datetime(2026, 7, 31, 21, 5, tzinfo=UTC), + ) now = datetime(2026, 8, 20, tzinfo=UTC) self.poll(now) outages, _, index = self.load(now) - self.assertEqual(len(outages), 1) - self.assertEqual(render.area_index(outages, index), []) - search = render.build(outages, index, now, self.until)[3] - self.assertNotIn("Rathfarnham", json.dumps(search)) + self.assertEqual([o.id for o in outages], ["2"]) + self.assertEqual(outages[0].chain, ()) + self.assertEqual([c for c, _ in render.area_index(outages, index)], ["Dublin"]) + self.assertEqual( + [len(events) for _, areas in render.area_index(outages, index) + for _, _, _, events in areas], [1]) class TestWhichAreasGetOne(unittest.TestCase): From bdb9971fa97a195045f64b641050264f37f38fe7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 12:06:24 +0000 Subject: [PATCH 08/12] Finish what this PR's own review found it had left half done - "up to 1 customers" on the county page's fault-spots card, the same plural slip fixed in the timeline. - _cell checked only the first character, so a formula behind a space or newline, which spreadsheet importers trim, went out live; the planned reason column is now under test too. - The area page's link into the app wrote the county into the hash raw, the one other producer of #county/ besides go(); it is now encoded and escaped. - The script test now also holds that something listens for the county rows' clicks, or a deleted listener would leave every row dead and CI green. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- esb_site/render.py | 13 ++++++++----- tests/test_site_county.py | 10 +++++++++- tests/test_site_model.py | 7 +++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/esb_site/render.py b/esb_site/render.py index 73d6731..b4fd5e7 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -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 @@ -760,7 +761,7 @@ def _spots_html(spots, since): f"
  • {html.escape(loc)}" '' f'{n} faults' - f'up to {peak:,} customers
  • ' + f'up to {peak:,} customer{"" if peak == 1 else "s"}' for loc, n, peak in spots ) return ( @@ -774,9 +775,11 @@ def _spots_html(spots, since): def _cell(text): - """ESB's free text, defused: a cell opening with a formula character is - run as a formula by the spreadsheet a reader opens the file in.""" - return "'" + text if text and text[0] in "=+-@\t\r" else 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): @@ -954,7 +957,7 @@ def area_page(county, name, pop, events, nearby, data): '" ) # the record first, what the page holds last - truncation must not turn diff --git a/tests/test_site_county.py b/tests/test_site_county.py index aebfcbd..cfa4314 100644 --- a/tests/test_site_county.py +++ b/tests/test_site_county.py @@ -360,11 +360,19 @@ def test_one_row_per_merged_event_with_every_id(self): self.assertEqual(tuple(rows[0]), render.CSV_COLUMNS) def test_a_location_that_reads_as_a_formula_is_published_as_text(self): - self.observe(detail("1", location='=HYPERLINK("http://x","Ballina")'), + self.observe(detail("1", location='=HYPERLINK("http://x","Ballina")', + outageType="Planned", plannedOutageReason=" @SUM(1)"), datetime(2026, 8, 10, 10, 0, tzinfo=UTC)) outages, _, _ = self.load(datetime(2026, 8, 20, tzinfo=UTC)) row = next(csv.DictReader(io.StringIO(render.county_csv(outages)))) self.assertEqual(row["location"], '\'=HYPERLINK("http://x","Ballina")') + # an unmapped reason, behind a space a spreadsheet would trim + self.assertTrue(row["planned_reason"].startswith("'"), row["planned_reason"]) + + def test_a_formula_behind_whitespace_is_defused_too(self): + for text in (" =1+1", "\n=1+1", "\t@x"): + self.assertTrue(render._cell(text).startswith("'"), repr(text)) + self.assertEqual(render._cell("Ballina"), "Ballina") def test_the_page_links_its_csv(self): self.observe(detail("1"), datetime(2026, 8, 10, 10, 0, tzinfo=UTC)) diff --git a/tests/test_site_model.py b/tests/test_site_model.py index 10ec608..469a5f4 100644 --- a/tests/test_site_model.py +++ b/tests/test_site_model.py @@ -1556,6 +1556,10 @@ def test_a_timeline_row_prints_dublins_time(self): line = render._update_line(["update", "2026-08-24T14:15", 40], False) self.assertIn("", line) + def test_a_fault_spot_of_one_customer_is_singular(self): + card = render._spots_html([("Skerries Road", 2, 1)], "31 July 2026") + self.assertIn("up to 1 customer<", card) + def test_one_customer_still_off_is_singular(self): line = render._update_line(["update", "2026-08-24T14:15", 1], False) self.assertIn("1 customer still off", line) @@ -1602,6 +1606,9 @@ class TestTheAppScript(unittest.TestCase): def test_no_name_is_spliced_into_an_inline_handler(self): self.assertNotIn("onclick=\"go(\\''", self.page) self.assertIn('data-county="\' + esc(r.name)', self.page) + # and something still listens, or no row opens its county + self.assertIn('getElementById("list").addEventListener("click"', self.page) + self.assertIn('go(row.getAttribute("data-county"))', self.page) def test_a_malformed_link_cannot_throw_out_of_route(self): route = self.page.split("function route(", 1)[1].split("\nfunction ", 1)[0] From 27bd678fbeb2a244275217ec9caa8119a5b368a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 12:17:56 +0000 Subject: [PATCH 09/12] Say "no data yet" for a month the data has not reached A build just after Dublin midnight on the 1st, from data to the night before, listed the new month under "to 30 Sep" on the county page, and the app headline read "0 faults and 0 planned outages" as though the month were over and quiet. The table's caveat now reads "no data yet", and the app says so in the headline and the county view and shows dashes, not zeros, in the tiles and the rows' counts. Owner's call; the note records it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- esb_site/render.py | 4 ++++ esb_site/site.html | 20 +++++++++++++------- notes/design-alignment.md | 19 +++++++++++++++++++ tests/test_site_model.py | 11 +++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/esb_site/render.py b/esb_site/render.py index b4fd5e7..87f75d1 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -579,6 +579,10 @@ def _month_watched(ym, until): """ lo, hi = model.month_bounds(ym) olo, ohi = model.observed_window(ym, until) + # Built after the month began, from data that has not reached it: zeros + # under "to 30 Sep" read as a quiet month. Mirrored in site.html. + if ohi <= olo: + return "no data yet" bits = [] if olo > lo: bits.append(f"from {model.local(olo):%-d %b}") diff --git a/esb_site/site.html b/esb_site/site.html index 22ffa7b..c4e22bd 100644 --- a/esb_site/site.html +++ b/esb_site/site.html @@ -335,8 +335,8 @@

    '' + esc(r.name) + "
    " + '
    ' + bar(m ? m[0] : "", curMonth) + "
    " + '
    ' + - "" + (m ? m[4] : 0) + "faults" + - "" + (m ? m[5] : 0) + "planned
    " + + "" + (noDataYet(curMonth) ? "–" : m ? m[4] : 0) + "faults" + + "" + (noDataYet(curMonth) ? "–" : m ? m[5] : 0) + "planned
    " + '
    ' + (m && m[2] != null ? m[2] + "%" : "–") + "
    restored in 4h
    " + '
    ›' + @@ -348,9 +348,10 @@

    // "so far" only while the viewed month is still collecting var partial = D.observed_month === curMonth; var ungraded = ungradedNote(rows, curMonth); + var none = noDataYet(curMonth); document.getElementById("natline").innerHTML = "" + monthLabelLong(curMonth) + (partial ? " so far" : "") + ": " + - 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: supply restored within " + @@ -377,13 +378,17 @@

    // the footer, and it already means faults alone. [fmtCml(n[0]), "customer minutes lost"] ].map(function (t) { - return '
    ' + esc(t[0]) + '
    ' + esc(t[1]) + "
    "; + return '
    ' + esc(none ? "–" : t[0]) + '
    ' + esc(t[1]) + "
    "; }).join(""); document.getElementById("legend").innerHTML = legendHtml(); } /* --- county -------------------------------------------------------------- */ +// The build ran after this month began, from data that has not reached it: +// zeros would read as a quiet month. Mirrored in render.py (_month_watched). +function noDataYet(ym) { return ym > D.observed_month; } + function renderCounty() { var el = document.getElementById("countyview"), c = curCounty; var m = (D.stats[c] || {})[curMonth]; @@ -418,15 +423,16 @@

    [m ? m[5] : 0, "planned outages"], [m ? num(m[6]) : 0, "customers hit by faults"] ].map(function (t) { - return '
    ' + esc(t[0]) + '
    ' + esc(t[1]) + "
    "; + return '
    ' + esc(noDataYet(curMonth) ? "–" : t[0]) + '
    ' + esc(t[1]) + "
    "; }).join("") + "
    "; var body; if (loading) body = '
    Loading outages…
    '; else if (failed) body = '
    Could not load the outages for ' + esc(c) + ". Try reloading the page.
    "; - else if (!cases.length) body = '
    No outages recorded in ' + - esc(c) + " in " + monthLabel(curMonth) + ".
    "; + else if (!cases.length) body = '
    ' + (noDataYet(curMonth) + ? "No data yet for " + monthLabelLong(curMonth) + : "No outages recorded in " + esc(c) + " in " + monthLabel(curMonth)) + ".
    "; else body = '
    ' + cases.map(caseHtml).join("") + "
    "; el.innerHTML = head + summary + body; diff --git a/notes/design-alignment.md b/notes/design-alignment.md index 7ad7e4e..f75eafe 100644 --- a/notes/design-alignment.md +++ b/notes/design-alignment.md @@ -618,3 +618,22 @@ page and that is the unit a reader arrives at; 534 KB in 26 files, listed in the size report as "on request" and outside the budget. Written for every county, an empty one included, so the link cannot 404. Not in the sitemap: a CSV is not a page. + +## A month the data has not reached says "no data yet" - 2026-09-24 + +A build that runs just after Dublin midnight on the 1st, from data that stops +the night before, lists the new month with nothing in it. The county page's +month table printed the previous month's last day under it ("October 2026 / +to 30 Sep"), and the app headline read "October 2026: 0 faults and 0 planned +outages" with no "so far", as though the month had come and gone quiet. +Pushes land every six hours, so this is a few hours at the start of every +month, and longer whenever the collector is down across one. + +Owner's call: say **no data yet**. The month table's caveat reads "no data +yet" (`render._month_watched`, when the observed window is empty); the app +(`noDataYet` in site.html, a month later than `observed_month`) puts it in the +headline, shows "–" in the tiles and the county rows' counts, and says "No data +yet for October 2026." where the county view would list outages. Rejected: +leaving the month out until data arrives, which would make the month list +depend on the collector rather than the calendar, the thing `month_list` +was fixed to stop doing (2026-08-26). diff --git a/tests/test_site_model.py b/tests/test_site_model.py index 469a5f4..47adbb0 100644 --- a/tests/test_site_model.py +++ b/tests/test_site_model.py @@ -1568,6 +1568,17 @@ def test_the_caveat_names_dublins_day(self): until = datetime(2026, 9, 9, 23, 30, tzinfo=UTC) self.assertEqual(render._month_watched("2026-09", until), "to 10 Sep") + def test_a_month_the_data_has_not_reached_says_so(self): + # built at 00:20 on 1 October in Dublin, from data to 23:45 the night before + until = datetime(2026, 9, 30, 22, 45, tzinfo=UTC) + self.assertEqual(render._month_watched("2026-10", until), "no data yet") + self.assertEqual(render._month_watched("2026-09", until), "to 30 Sep") + + def test_the_app_says_no_data_yet_too(self): + page = (Path(model.__file__).parent / "site.html").read_text() + self.assertIn("function noDataYet(ym) { return ym > D.observed_month; }", page) + self.assertIn('(none ? "no data yet" :', page) + def test_the_horizon_is_shown_and_filed_in_dublin(self): until = datetime(2026, 8, 31, 23, 30, tzinfo=UTC) data = render.build([], model.SmallAreaIndex.load(), until, until)[0] From f2e32924973f5dfd9d3108097a9189abb15862dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 12:18:09 +0000 Subject: [PATCH 10/12] Drop an unsourced date from the no-data-yet note Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- notes/design-alignment.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notes/design-alignment.md b/notes/design-alignment.md index f75eafe..71b2975 100644 --- a/notes/design-alignment.md +++ b/notes/design-alignment.md @@ -635,5 +635,5 @@ yet" (`render._month_watched`, when the observed window is empty); the app headline, shows "–" in the tiles and the county rows' counts, and says "No data yet for October 2026." where the county view would list outages. Rejected: leaving the month out until data arrives, which would make the month list -depend on the collector rather than the calendar, the thing `month_list` -was fixed to stop doing (2026-08-26). +depend on the collector rather than the calendar; `month_list` walks the +calendar on purpose, so a month is listed from its first instant. From 36152e4ddea48ddae2b88a8262d9c7c768b74dbd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 12:23:31 +0000 Subject: [PATCH 11/12] Decide "no data yet" once, in Python, and let nothing count in such a month Review of this PR found the first cut used two rules: the page tested for an empty observed window, the app compared months with observed_month, and on a horizon landing exactly on Dublin midnight they disagreed, the app back to "so far: 0 faults". model.month_watched now decides it and ships it as D.nodata, observed_month is taken just before the horizon like partial_days, and the rest follows: - the county table shows dashes, not zeros, for such a month - the gate sentence says there is no data rather than promising a grade date - nothing is counted, nationally or per county, or filed in the shard for it, though a published restore time past the horizon used to slip in - CLAUDE.md gets its Settled row, and the merge comment says which end the collection-start filter uses Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- CLAUDE.md | 1 + esb_site/model.py | 15 ++++++++++--- esb_site/render.py | 31 ++++++++++++++++++++------- esb_site/site.html | 13 ++++++------ notes/design-alignment.md | 16 +++++++++----- tests/test_site_model.py | 42 ++++++++++++++++++++++++++++++++++--- tests/test_site_national.py | 3 ++- 7 files changed, 95 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ad568a..faca2f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/esb_site/model.py b/esb_site/model.py index 9b638ed..a57be0f 100644 --- a/esb_site/model.py +++ b/esb_site/model.py @@ -926,8 +926,9 @@ def load_outages(db_path, sa_index, now): ) # 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 after the merge, whose event - # ends with its last member, and before the chains are labelled. + # 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: @@ -961,6 +962,14 @@ def observed_window(ym, until): return max(lo, COLLECTION_START), min(hi, until) +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. @@ -999,7 +1008,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 hi <= lo or o.end <= lo or o.start >= hi: continue cm = o.customer_minutes(lo, hi) if o.planned: diff --git a/esb_site/render.py b/esb_site/render.py index 87f75d1..60305cb 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -153,7 +153,9 @@ 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 hi > lo and o.start and o.end and o.end > lo and o.start < 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. @@ -223,8 +225,12 @@ 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}", + # months listed before any of their data exists: said once, here, so + # the app cannot work it out differently from the county page + "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. @@ -297,7 +303,10 @@ def shard(outages, months, until): tiles and missing from that month's list, so a reader could count the rows and come up one short of the headline. """ - windows = [(ym,) + model.observed_window(ym, until) for ym in months] + windows = [ + (ym,) + model.observed_window(ym, until) for ym in months + if model.month_watched(ym, until) + ] by_month = defaultdict(list) for o in sorted(outages, key=lambda o: o.start, reverse=True): record = None @@ -511,6 +520,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 @@ -577,12 +588,10 @@ 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) - # Built after the month began, from data that has not reached it: zeros - # under "to 30 Sep" read as a quiet month. Mirrored in site.html. - if ohi <= olo: - return "no data yet" bits = [] if olo > lo: bits.append(f"from {model.local(olo):%-d %b}") @@ -621,6 +630,12 @@ 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'{month_label(ym)}{watched}' + + "–" * 8 + "" + ) + continue rows.append( f'{month_label(ym)}' + (f'{watched}' if watched else "") diff --git a/esb_site/site.html b/esb_site/site.html index c4e22bd..e38832c 100644 --- a/esb_site/site.html +++ b/esb_site/site.html @@ -267,6 +267,7 @@

    // 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"; @@ -328,6 +329,7 @@

    }); 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 '
    ' + @@ -335,8 +337,8 @@

    '' + esc(r.name) + "
    " + '
    ' + bar(m ? m[0] : "", curMonth) + "
    " + '
    ' + - "" + (noDataYet(curMonth) ? "–" : m ? m[4] : 0) + "faults" + - "" + (noDataYet(curMonth) ? "–" : m ? m[5] : 0) + "planned
    " + + "" + (none ? "–" : m ? m[4] : 0) + "faults" + + "" + (none ? "–" : m ? m[5] : 0) + "planned
    " + '
    ' + (m && m[2] != null ? m[2] + "%" : "–") + "
    restored in 4h
    " + '
    ›' + @@ -348,7 +350,6 @@

    // "so far" only while the viewed month is still collecting var partial = D.observed_month === curMonth; var ungraded = ungradedNote(rows, curMonth); - var none = noDataYet(curMonth); document.getElementById("natline").innerHTML = "" + monthLabelLong(curMonth) + (partial ? " so far" : "") + ": " + (none ? "no data yet" : plural(n[1], "fault") + " and " + plural(n[2], "planned outage")); @@ -385,9 +386,9 @@

    } /* --- county -------------------------------------------------------------- */ -// The build ran after this month began, from data that has not reached it: -// zeros would read as a quiet month. Mirrored in render.py (_month_watched). -function noDataYet(ym) { return ym > D.observed_month; } +// A month listed before any of its data exists, as render.py decided it once +// (model.month_watched); zeros there would read as a quiet month. +function noDataYet(ym) { return (D.nodata || []).indexOf(ym) >= 0; } function renderCounty() { var el = document.getElementById("countyview"), c = curCounty; diff --git a/notes/design-alignment.md b/notes/design-alignment.md index 71b2975..e5eed04 100644 --- a/notes/design-alignment.md +++ b/notes/design-alignment.md @@ -629,11 +629,17 @@ outages" with no "so far", as though the month had come and gone quiet. Pushes land every six hours, so this is a few hours at the start of every month, and longer whenever the collector is down across one. -Owner's call: say **no data yet**. The month table's caveat reads "no data -yet" (`render._month_watched`, when the observed window is empty); the app -(`noDataYet` in site.html, a month later than `observed_month`) puts it in the -headline, shows "–" in the tiles and the county rows' counts, and says "No data -yet for October 2026." where the county view would list outages. Rejected: +Owner's call: say **no data yet**. Whether a month is watched at all is +decided once, `model.month_watched`, and shipped as `D.nodata`: a first cut +let the app work it out again from `observed_month`, and the two disagreed on +a horizon landing exactly on Dublin midnight. The month table's caveat reads +"no data yet" and its figures are dashes; the gate sentence says "There is no +data yet for October 2026" rather than promising a grade date; the app puts it +in the headline, shows "–" in the tiles and the county rows' counts, and says +so where the county view would list outages. Nothing is counted or filed in +such a month, including an outage whose published restore time runs past the +horizon. Not covered: a whole month the collector missed reads as watched +once data resumes after it, the in-window gap grading.md already names. Rejected: leaving the month out until data arrives, which would make the month list depend on the collector rather than the calendar; `month_list` walks the calendar on purpose, so a month is listed from its first instant. diff --git a/tests/test_site_model.py b/tests/test_site_model.py index 47adbb0..004aaac 100644 --- a/tests/test_site_model.py +++ b/tests/test_site_model.py @@ -1574,10 +1574,28 @@ def test_a_month_the_data_has_not_reached_says_so(self): self.assertEqual(render._month_watched("2026-10", until), "no data yet") self.assertEqual(render._month_watched("2026-09", until), "to 30 Sep") - def test_the_app_says_no_data_yet_too(self): + def test_the_app_reads_no_data_yet_from_the_payload_everywhere(self): page = (Path(model.__file__).parent / "site.html").read_text() - self.assertIn("function noDataYet(ym) { return ym > D.observed_month; }", page) - self.assertIn('(none ? "no data yet" :', page) + self.assertIn("function noDataYet(ym) { return (D.nodata || []).indexOf(ym) >= 0; }", + page) + for surface in ( + '(none ? "no data yet" :', # national headline + 'esc(none ? "–" : t[0])', # national tiles + '(none ? "–" : m ? m[4] : 0)', # county rows + 'esc(noDataYet(curMonth) ? "–" : t[0])', # county tiles + '"No data yet for " + monthLabelLong(curMonth)', # county list + 'if (noDataYet(ym)) return "There is no data yet for "', # gate sentence + ): + self.assertIn(surface, page) + + def test_a_horizon_on_the_stroke_of_midnight_has_watched_none_of_the_month(self): + until = datetime(2026, 9, 30, 23, 0, tzinfo=UTC) # 00:00 on 1 October in Dublin + data = render.build([], model.SmallAreaIndex.load(), until, until)[0] + self.assertEqual((data["observed_month"], data["nodata"]), ("2026-09", ["2026-10"])) + self.assertEqual(render._month_watched("2026-10", until), "no data yet") + self.assertEqual( + render.ungraded_reason("2026-10", 0, until), "There is no data yet for October 2026" + ) def test_the_horizon_is_shown_and_filed_in_dublin(self): until = datetime(2026, 8, 31, 23, 30, tzinfo=UTC) @@ -1599,6 +1617,24 @@ def body(name): self.assertIn("D.observed_month === curMonth", page) +class TestAMonthTheDataHasNotReached(SiteModelCase): + def test_a_restore_published_past_the_horizon_is_not_counted_in_it(self): + # listed at 22:45 UTC on 30 Sep, with ESB naming a restore at 00:30 on + # 1 October, which is after the data stops + seen = datetime(2026, 9, 30, 22, 45, tzinfo=UTC) + self.observe(detail("1", outageType="Restored", startTime="30/09/2026 21:00", + restoreTime="01/10/2026 00:30"), seen) + self.poll(seen) + outages, _, index = self.load(datetime(2026, 10, 1, 0, 20, tzinfo=UTC)) + now = datetime(2026, 10, 1, 0, 20, tzinfo=UTC) + s = model.county_month(outages, "Dublin", index.customers["Dublin"], "2026-10", + now, self.until) + self.assertEqual((s["faults"], s["planned"]), (0, 0)) + data = render.build(outages, index, now, self.until)[0] + self.assertEqual(data["national"]["2026-10"][1:3], [0, 0]) + self.assertNotIn("2026-10", render.shard(outages, ["2026-09", "2026-10"], self.until)) + + class TestTheBuildClock(unittest.TestCase): def test_an_offset_is_converted_not_dropped(self): from esb_site.__main__ import parse_now diff --git a/tests/test_site_national.py b/tests/test_site_national.py index 61e72a4..84f6845 100644 --- a/tests/test_site_national.py +++ b/tests/test_site_national.py @@ -253,7 +253,8 @@ def test_the_payload_carries_no_per_outage_records(self): self.assertEqual( set(self.data), { - "generated", "observed", "observed_iso", "observed_month", "stale_hours", + "generated", "observed", "observed_iso", "observed_month", "nodata", + "stale_hours", "partial", "daygate", "compare", "start", "months", "esb", "counties", "customers", "stats", "national", From 7a698fb4b35bd0a992df05685d7d1926207d3b93 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 12:44:50 +0000 Subject: [PATCH 12/12] Tie "so far" to the newest watched month, and guard the window once A review of the last commit found it had moved observed_month a microsecond back but left the app's "so far" and "this month" keyed on it, so for the first hours of every month the app called September "so far" while the county page had closed it. "So far" now needs the newest listed month, with data in it. Also: - one model.overlaps rejects an empty window for county_month, the national row and the shard, instead of three copies of the guard - _daygate ships no grade date for a month with no data - the no-data table row carries the grade chip and its reason, and a test holds every row's cell count to the header - a data.js cached from before `nodata` falls back to comparing months - comments no longer restate the settled decision Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UqWuH6frirpbQDKnF5rEdT --- esb_site/model.py | 9 ++++++++- esb_site/render.py | 20 ++++++++------------ esb_site/site.html | 11 ++++++----- tests/test_site_model.py | 26 ++++++++++++++++++++++++-- 4 files changed, 46 insertions(+), 20 deletions(-) diff --git a/esb_site/model.py b/esb_site/model.py index a57be0f..38e339d 100644 --- a/esb_site/model.py +++ b/esb_site/model.py @@ -962,6 +962,13 @@ 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 @@ -1008,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 hi <= lo or o.end <= lo or o.start >= hi: + if not overlaps(o, lo, hi): continue cm = o.customer_minutes(lo, hi) if o.planned: diff --git a/esb_site/render.py b/esb_site/render.py index 60305cb..90197de 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -153,9 +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 hi > lo and 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. @@ -228,8 +226,6 @@ def entries(county, names): # 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}", - # months listed before any of their data exists: said once, here, so - # the app cannot work it out differently from the county page "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 @@ -303,15 +299,12 @@ def shard(outages, months, until): tiles and missing from that month's list, so a reader could count the rows and come up one short of the headline. """ - windows = [ - (ym,) + model.observed_window(ym, until) for ym in months - if model.month_watched(ym, until) - ] + windows = [(ym,) + model.observed_window(ym, until) for ym in months] by_month = defaultdict(list) 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 @@ -506,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 @@ -633,7 +628,8 @@ def _county_months_html(county, data, months, until): if not model.month_watched(ym, until): rows.append( f'{month_label(ym)}{watched}' - + "–" * 8 + "" + f"{_grade_chip(None, reason=ungraded_reason(ym, 0, until))}" + + "–" * 7 + "" ) continue rows.append( diff --git a/esb_site/site.html b/esb_site/site.html index e38832c..d244384 100644 --- a/esb_site/site.html +++ b/esb_site/site.html @@ -347,8 +347,10 @@

    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 = "" + monthLabelLong(curMonth) + (partial ? " so far" : "") + ": " + @@ -386,9 +388,8 @@

    } /* --- county -------------------------------------------------------------- */ -// A month listed before any of its data exists, as render.py decided it once -// (model.month_watched); zeros there would read as a quiet month. -function noDataYet(ym) { return (D.nodata || []).indexOf(ym) >= 0; } +// 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; diff --git a/tests/test_site_model.py b/tests/test_site_model.py index 004aaac..0e3630b 100644 --- a/tests/test_site_model.py +++ b/tests/test_site_model.py @@ -1576,8 +1576,10 @@ def test_a_month_the_data_has_not_reached_says_so(self): def test_the_app_reads_no_data_yet_from_the_payload_everywhere(self): page = (Path(model.__file__).parent / "site.html").read_text() - self.assertIn("function noDataYet(ym) { return (D.nodata || []).indexOf(ym) >= 0; }", - page) + self.assertIn("function noDataYet(ym) { return D.nodata ? D.nodata.indexOf(ym) >= 0", page) + # "so far" belongs to the newest listed month only, and only once data reaches it + self.assertIn("var partial = curMonth === D.months[D.months.length - 1] && " + "!noDataYet(curMonth)", page) for surface in ( '(none ? "no data yet" :', # national headline 'esc(none ? "–" : t[0])', # national tiles @@ -1635,6 +1637,26 @@ def test_a_restore_published_past_the_horizon_is_not_counted_in_it(self): self.assertNotIn("2026-10", render.shard(outages, ["2026-09", "2026-10"], self.until)) +class TestTheCountyTableForAMonthWithNoData(unittest.TestCase): + def test_the_row_has_a_cell_per_column_and_shows_no_zeros(self): + import re + + until = datetime(2026, 9, 30, 22, 45, tzinfo=UTC) + now = datetime(2026, 9, 30, 23, 20, tzinfo=UTC) # 00:20 on 1 October + index = model.SmallAreaIndex.load() + data, _, months, _ = render.build([], index, now, until) + table = render._county_months_html("Dublin", data, months, until) + heads = len(re.findall(r'(.*?)", table.split("")[1]) + october = next(r for r in rows if "October 2026" in r) + for row in rows: + self.assertEqual(row.count("0<", october) + self.assertNotIn("2026-10", data["daygate"]) + + class TestTheBuildClock(unittest.TestCase): def test_an_offset_is_converted_not_dropped(self): from esb_site.__main__ import parse_now