diff --git a/CLAUDE.md b/CLAUDE.md index 9a23ce2..892a799 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 | +| 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 | | Part-observed days keep their colour and say so in the tooltip | `notes/grading.md` § Short days say so | diff --git a/esb_site/model.py b/esb_site/model.py index c9a7fc5..fb91c1e 100644 --- a/esb_site/model.py +++ b/esb_site/model.py @@ -12,6 +12,7 @@ from __future__ import annotations +import calendar import csv import math import sqlite3 @@ -19,6 +20,7 @@ from datetime import UTC, date, datetime, timedelta from pathlib import Path from typing import NamedTuple +from zoneinfo import ZoneInfo DATA_DIR = Path(__file__).parent / "data" SA_POP_PATH = DATA_DIR / "sa_pop.csv" @@ -30,6 +32,9 @@ # it. Days before it are rendered as "no data", never as "no outages". COLLECTION_START = datetime(2026, 7, 31, 21, 2, 11, tzinfo=UTC) +# notes/grading.md § Months and days are Dublin's +DUBLIN = ZoneInfo("Europe/Dublin") + # The denominator for CML and CI. Both figures ESB publishes point at the same # number: the Distribution System Statistics in DAPR 2024 give "c. 2.5 million # customer meters", and the company page says "roughly 2.5 million customers @@ -163,21 +168,34 @@ def fmt_utc(dt): return dt.strftime("%Y-%m-%dT%H:%M:%SZ") if dt else "" +def local(dt): + """An instant on the Dublin wall clock, which months and days are cut by.""" + return dt.astimezone(DUBLIN) + + +def midnight(day): + """The UTC instant a Dublin day begins. Every boundary is converted back + to UTC, because subtracting two datetimes that share a zone subtracts their + wall clocks and loses the hour at a clock change.""" + return datetime(day.year, day.month, day.day, tzinfo=DUBLIN).astimezone(UTC) + + def month_bounds(ym): year, month = int(ym[:4]), int(ym[5:7]) - lo = datetime(year, month, 1, tzinfo=UTC) - hi = datetime(year + (month == 12), month % 12 + 1, 1, tzinfo=UTC) + lo = midnight(date(year, month, 1)) + hi = midnight(date(year + (month == 12), month % 12 + 1, 1)) return lo, hi def month_list(start, end): - """Every month from start's to end's, inclusive. + """Every Dublin month from start's to end's, inclusive. Walked as (year, month) rather than as datetimes: COLLECTION_START is the first poll's exact instant, and a cursor carrying its 21:02 clock time hid each new month until its first evening. """ months = [] + start, end = local(start), local(end) year, month = start.year, start.month while (year, month) <= (end.year, end.month): months.append(f"{year:04d}-{month:02d}") @@ -920,7 +938,10 @@ def partial_days(until): was. The colour still says what was seen; these dates let the page say the day was short. """ - days = {COLLECTION_START.date(), (until - timedelta(microseconds=1)).date()} + days = { + local(COLLECTION_START).date(), + local(until - timedelta(microseconds=1)).date(), + } return sorted(d.isoformat() for d in days) @@ -958,7 +979,6 @@ def county_month(outages, county, customers, ym, now, until): lo, hi = observed_window(ym, until) observed_minutes = max((hi - lo).total_seconds() / 60.0, 1.0) observed_days = observed_minutes / 1440.0 - month_lo, month_hi = month_bounds(ym) fault_cm = planned_cm = 0.0 faults = planned = 0 @@ -1001,28 +1021,27 @@ def county_month(outages, county, customers, ym, now, until): for seg_start, seg_end, seg_customers in o.segments: cur, stop = max(seg_start, lo), min(seg_end, hi) while cur < stop: - nxt = (cur + timedelta(days=1)).replace( - hour=0, minute=0, second=0, microsecond=0 - ) - seg = min(stop, nxt) + day = local(cur).date() + seg = min(stop, midnight(day + timedelta(days=1))) if o.planned: - per_day_planned.add(cur.date()) + per_day_planned.add(day) else: - per_day_fault[cur.date()] += ( + per_day_fault[day] += ( seg_customers * (seg - cur).total_seconds() / 60.0 ) cur = seg cml = fault_cm / customers annualised = cml * MINUTES_PER_YEAR / observed_minutes - days_in_month = (month_hi - month_lo).days + year, month = int(ym[:4]), int(ym[5:7]) cells = [] - for d in range(1, days_in_month + 1): - day = date(month_lo.year, month_lo.month, d) - day_lo = datetime(day.year, day.month, day.day, tzinfo=UTC) + # Never from the bounds: a Dublin March is 23 hours short and October 25 long. + for d in range(1, calendar.monthrange(year, month)[1] + 1): + day = date(year, month, d) + day_lo, day_hi = midnight(day), midnight(day + timedelta(days=1)) if day_lo >= now: cells.append(DAY_FUTURE) - elif day_lo + timedelta(days=1) <= COLLECTION_START or day_lo >= until: + elif day_hi <= COLLECTION_START or day_lo >= until: # Either side of the collected window is "no data". A day the # collector never reached is not a day without outages, and # colouring it would publish an all-clear nobody checked. diff --git a/esb_site/render.py b/esb_site/render.py index ce84ee8..7a1581d 100644 --- a/esb_site/render.py +++ b/esb_site/render.py @@ -14,7 +14,7 @@ import html import io from collections import defaultdict -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from pathlib import Path import statusui @@ -67,13 +67,20 @@ def _short(dt): - """Timestamps are rendered, never computed on, so minutes are enough.""" + """Record timestamps are UTC to the minute; see _local for printing them.""" return dt.strftime("%Y-%m-%dT%H:%M") if dt else None +def _local(ts): + """A record timestamp on the Dublin wall clock. Mirrored in site.html.""" + dt = datetime.fromisoformat(ts).replace(tzinfo=UTC) + return model.local(dt).strftime("%Y-%m-%dT%H:%M") + + def _when_at(ts, ref): """A timestamp against the outage's start day: the clock time alone when it falls on the same day, the full day otherwise. Mirrored in site.html.""" + ts, ref = _local(ts), _local(ref) if ts[:10] == ref[:10]: return ts[11:16] return f"{_fmt_day(ts)}, {ts[11:16]}" @@ -212,13 +219,15 @@ def entries(county, names): # from a collector that stopped. Formatted for display here - it is # only ever shown, and the footer says "Data to {observed}". "observed": ( - f"{statusui.fmt_date(until.date().isoformat(), now.date())}," - f" {until:%H:%M} UTC" + f"{statusui.fmt_date(model.local(until).date().isoformat(), model.local(now).date())}," + f" {model.local(until):%H:%M}" ), # The same instant for freshness(), which dates the page against the # 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}", "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. @@ -237,7 +246,7 @@ def entries(county, names): (model.national_ci(outages, until) / model.ESB_NATIONAL_CI - 1) * 100 ), }, - "start": model.COLLECTION_START.strftime("%-d %B %Y"), + "start": model.local(model.COLLECTION_START).strftime("%-d %B %Y"), "months": months, "esb": { "national": model.ESB_NATIONAL_CML, @@ -386,7 +395,8 @@ def _case_html(k, horizon): chain = k[8] bits = [f"{k[3]:,} customer" + ("" if k[3] == 1 else "s") + " affected"] if k[4]: - bits.append(f"began {_fmt_day(k[4])}, {k[4][11:16]}") + began = _local(k[4]) + bits.append(f"began {_fmt_day(began)}, {began[11:16]}") if k[4] and k[5]: hours = ( datetime.fromisoformat(k[5]) - datetime.fromisoformat(k[4]) @@ -446,7 +456,7 @@ def _update_line(row, key, planned=False): + (" still off" if kind == "update" else "") ) cls = ' class="key"' if key else "" - return f"{' · '.join(bits)}" + return f"{' · '.join(bits)}" def _updates_html(rows, planned=False): @@ -492,7 +502,7 @@ def _daygate(months, until): """ gates = ((ym, model.days_gate(ym, until)) for ym in months) return { - ym: "" if when >= model.month_bounds(ym)[1] else f"{when:%-d %B}" + ym: "" if when >= model.month_bounds(ym)[1] else f"{model.local(when):%-d %B}" for ym, when in gates if when is not None } @@ -509,7 +519,10 @@ def ungraded_reason(ym, faults, until): # past the month's end: it can never reach five days, so promise no date if when >= model.month_bounds(ym)[1]: return f"Only part of {month_label(ym)} was watched, so it is not graded" - return f"{month_label(ym)} is too new to grade. Grades appear from {when:%-d %B}" + return ( + f"{month_label(ym)} is too new to grade. " + f"Grades appear from {model.local(when):%-d %B}" + ) if faults < model.MIN_GRADED_FAULTS: return f"Too few faults in {month_label(ym)} to grade fairly" # Past both gates, nothing was judged: every fault that started in the month @@ -571,9 +584,9 @@ def _month_watched(ym, until): olo, ohi = model.observed_window(ym, until) bits = [] if olo > lo: - bits.append(f"from {olo:%-d %b}") + bits.append(f"from {model.local(olo):%-d %b}") if ohi < hi: - bits.append(f"to {ohi:%-d %b}") + bits.append(f"to {model.local(ohi):%-d %b}") return " ".join(bits) @@ -1035,7 +1048,7 @@ def write(site_dir, outages, sa_index, now, until): ) area_paths.append(rel) - lastmod = now.strftime("%Y-%m-%d") + lastmod = model.local(now).strftime("%Y-%m-%d") paths = ( [""] + [f"c/{slug(c)}.html" for c in sa_index.counties] diff --git a/esb_site/site.html b/esb_site/site.html index eb279f7..1d98393 100644 --- a/esb_site/site.html +++ b/esb_site/site.html @@ -156,9 +156,27 @@

function fmtCml(v) { return v >= 100 ? Math.round(v) : v.toFixed(1); } +// A record timestamp on the Dublin wall clock. Mirrored in render.py (_local). +// Guarded because an engine without zone data throws here, and UTC times are +// a better failure than an app that never renders. +var DUBLIN = null; +try { + DUBLIN = new Intl.DateTimeFormat("en-GB", { + timeZone: "Europe/Dublin", hourCycle: "h23", + year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" + }); +} catch (e) {} +function local(ts) { + if (!DUBLIN) return ts; + var p = {}; + DUBLIN.formatToParts(new Date(ts + "Z")).forEach(function (x) { p[x.type] = x.value; }); + return p.year + "-" + p.month + "-" + p.day + "T" + p.hour + ":" + p.minute; +} + // a timestamp against the outage's start day: the clock time alone when it // falls on the same day, the full day otherwise. Mirrored in render.py. function whenAt(ts, ref) { + ts = local(ts); ref = local(ref); if (ts.slice(0, 10) === ref.slice(0, 10)) return ts.slice(11, 16); return fmtDay(ts) + ", " + ts.slice(11, 16); } @@ -328,7 +346,7 @@

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_iso.slice(0, 7) === curMonth; + var partial = D.observed_month === curMonth; var ungraded = ungradedNote(rows, curMonth); document.getElementById("natline").innerHTML = "" + monthLabelLong(curMonth) + (partial ? " so far" : "") + ": " + @@ -419,7 +437,8 @@

var planned = k[2], ups = k[9], chain = k[8]; var dur = k[5] && k[4] ? (new Date(k[5] + "Z") - new Date(k[4] + "Z")) / 3600000 : null; var bits = [plural(k[3], "customer") + " affected"]; - if (k[4]) bits.push("began " + fmtDay(k[4]) + ", " + k[4].slice(11, 16)); + var began = k[4] && local(k[4]); + if (began) bits.push("began " + fmtDay(began) + ", " + began.slice(11, 16)); if (dur != null) bits = bits.concat(endBits(k, dur)); // in the chip rather than trailing the timings: it is the row's most human // fact and it was in its least-read position @@ -451,7 +470,7 @@

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" : "")); - return '
  • " + bits.join(" · ") + "
  • "; + return '
  • " + bits.join(" · ") + "
  • "; } function updateList(ups, planned) { diff --git a/notes/grading.md b/notes/grading.md index bca0c0b..b7313e2 100644 --- a/notes/grading.md +++ b/notes/grading.md @@ -785,3 +785,55 @@ installed package. **To change the shared UI now:** edit in `../statusui`, test then `../statusui/rollout.sh` bumps the pin in all three sites, runs each site's tests and opens the PRs. An unpushed statusui change can be tried here with `uv run --with-editable ../statusui python -m esb_site ...`. + +## Months and days are Dublin's; the arithmetic stays UTC (2026-09-24) + +Until this date every month, day cell, gate date and printed time on the site +was UTC, and none of it said so. ESB publishes Dublin wall-clock times, so from +April to October every time a reader saw was an hour behind ESB's own: an +outage ESB lists as starting at 15:15 read "began 14:15". The buckets had the +same hour's error. A fault at 00:30 on 1 September, Irish time, counted in +August, coloured the 31 August cell, and sat on August's page. lifts found and +fixed the same thing on 2026-08-18 (`lifts/notes/site.md` § Displayed instants +are Dublin wall-clock, and so are the day buckets); uisce still cuts at UTC. + +The rule is the owner's: **store and compute in UTC, display in local time.** + +- **Boundaries** are Dublin midnights converted back to UTC instants + (`model.midnight`). They are never left as Dublin-zoned datetimes, because + Python subtracts two datetimes that share a zone by their wall clocks, which + loses the hour at a clock change. Every window, overlap test and + customer-minute sum still runs on UTC. +- **Day cells** are counted with `calendar.monthrange`, never from the bounds: + a Dublin March is 23 hours short and October 25 hours long. +- **Case records stay UTC.** This is where esb differs from lifts, which ships + Dublin wall-clock strings and precomputes every duration because subtracting + offset-free wall-clock strings loses the October hour. Here the durations, + the estimate deltas and the horizon comparison keep subtracting UTC strings, + unchanged. Only what is printed turns Dublin: `render._local` and its + mirror `local()` in site.html, which uses `Intl.DateTimeFormat` with + `timeZone: "Europe/Dublin"`. The page shows Dublin whatever the reader's + own zone is, and the payload does not grow. +- **`observed`**, the "Data to ..." hover title, is Dublin wall-clock and no + longer says "UTC". `observed_month` is new, and gives the Dublin month for + the "so far" wording. `observed_iso` stays a UTC instant for `freshness()`. + `generated` keeps statusui's shared UTC stamp, and the CSV keeps its `*_utc` + columns. + +Accepted edge: in the hour the clocks go back in October, 01:00 to 02:00 +happens twice, and the printed times carry no offset. An outage from 01:50 +summer time to 01:10 winter time reads "began 01:50 · restored 01:10 (20 min)". +The span is right, because it is taken in UTC, but the clock times read +backwards. It is one hour a year. Labelling offsets on every row to cover it +would cost more than the confusion it saves. + +Checked in Chromium with the browser set to New York: all 856 Dublin and Cork +records render the same summary line and timeline times from the JS as from +render.py. + +On the corpus to 24 September no grade letter moves. Day cells shift in 13 +county-months. Dublin's monthly customer minutes lost moves by 0.1 in both +months. One Monaghan fault that began just before midnight on 31 July, Irish +time, moves from July into August (Monaghan August 99.5% to 98.9% restored in +4 h), which leaves July empty: under Dublin months it holds under two observed hours, from 21:02 to 23:00 UTC. National September +customer minutes lost goes from 11.9 to 12.0. diff --git a/tests/test_site_county.py b/tests/test_site_county.py index f0f56c1..bd169c1 100644 --- a/tests/test_site_county.py +++ b/tests/test_site_county.py @@ -179,7 +179,7 @@ def test_a_month_watched_end_to_end_carries_no_caveat(self): def test_a_part_watched_month_says_which_part(self): """Collection began on 31 July and the horizon stops mid-September, so those two rows are built from less time than the months beside them. A - row of zeros for three hours of July reads as a quiet month otherwise.""" + row of zeros for two hours of July reads as a quiet month otherwise.""" july = re.search(r'July 2026(.*?)', self.page).group(1) self.assertIn("from 31 Jul", july) sept = re.search(r'September 2026(.*?)', self.page).group(1) @@ -392,7 +392,7 @@ def test_the_reason_is_on_the_page_not_only_in_a_hover(self): self.assertIn("September 2026 is too new to grade", self.text_of(page)) def test_a_month_that_can_never_reach_five_days_promises_no_date(self): - """Collection opened at 21:02 on 31 July, so July holds three hours and + """Collection opened at 21:02 on 31 July, so July holds two hours and the month is over. "Grades appear from 5 August" would be a lie.""" self.observe(detail("1"), datetime(2026, 8, 10, 10, 0, tzinfo=UTC)) self.poll(datetime(2026, 9, 10, 0, 0, tzinfo=UTC), n_listed=1) diff --git a/tests/test_site_model.py b/tests/test_site_model.py index c061c8a..500157e 100644 --- a/tests/test_site_model.py +++ b/tests/test_site_model.py @@ -694,6 +694,16 @@ def test_cells_cover_the_whole_month_and_mark_the_unobserved(self): # NOW is the 20th, so the 21st onward is still to come. self.assertEqual(set(s["cells"][20:]), {str(model.DAY_FUTURE)}) + def test_a_month_with_a_clock_change_still_has_a_cell_per_day(self): + # Dublin's March is 23 hours short and October 25 long; the cells + # count calendar days, not the hours between the bounds. + outages, _, index = self.load() + for ym, days in (("2027-03", 31), ("2026-10", 31), ("2027-02", 28)): + s = model.county_month( + outages, "Dublin", index.customers["Dublin"], ym, NOW, self.until + ) + self.assertEqual(len(s["cells"]), days, ym) + def test_days_before_collection_started_are_not_days_without_outages(self): outages, _, index = self.load() s = model.county_month( @@ -800,10 +810,11 @@ def test_days_past_the_horizon_are_not_days_without_outages(self): s = model.county_month( outages, "Dublin", index.customers["Dublin"], "2026-08", NOW, self.until ) - # Collection stopped on the 12th and NOW is the 20th: the 13th to the - # 19th are unwatched, and the 20th onward is still to come. - self.assertEqual(set(s["cells"][12:19]), {str(model.DAY_NO_DATA)}) - self.assertEqual(set(s["cells"][19:]), {str(model.DAY_FUTURE)}) + # Collection stopped on the 12th and NOW is 01:00 on the 20th in + # Dublin: the 13th to the 20th are unwatched, and the 21st onward is + # still to come. + self.assertEqual(set(s["cells"][12:20]), {str(model.DAY_NO_DATA)}) + self.assertEqual(set(s["cells"][20:]), {str(model.DAY_FUTURE)}) def test_the_measured_window_stops_at_the_horizon(self): """Time the collector was down is not time this site watched.""" @@ -813,7 +824,8 @@ def test_the_measured_window_stops_at_the_horizon(self): s = model.county_month( outages, "Dublin", index.customers["Dublin"], "2026-08", NOW, self.until ) - self.assertAlmostEqual(s["observed_days"], 11.25, places=2) + # Dublin's August began at 23:00 UTC on 31 July: 11 days and 7 hours + self.assertAlmostEqual(s["observed_days"], 11 + 7 / 24, places=4) class TestOngoingOutages(SiteModelCase): @@ -933,30 +945,30 @@ class TestShardMonths(SiteModelCase): """The list under a month and the tiles above it count the same outages.""" def test_an_outage_crossing_midnight_on_the_last_is_listed_in_both(self): - # 00:00 Dublin on 1 August is 23:00 UTC on 31 July, so this one is - # counted in both months. Filed by its start month alone it went - # missing from August's list while August's fault tile still counted - # it, and a reader could count the rows and come up one short. + # 23:30 to 01:00 across Dublin's midnight into September, so this one + # is counted in both months. Filed by its start month alone it went + # missing from September's list while September's fault tile still + # counted it, and a reader could count the rows and come up one short. self.observe( - detail("1", startTime="01/08/2026 00:00"), - datetime(2026, 7, 31, 23, 30, tzinfo=UTC), + detail("1", startTime="31/08/2026 23:30"), + datetime(2026, 8, 31, 23, 0, tzinfo=UTC), ) self.observe( detail( "1", - startTime="01/08/2026 00:00", + startTime="31/08/2026 23:30", outageType="Restored", - restoreTime="01/08/2026 12:00", + restoreTime="01/09/2026 01:00", ), - datetime(2026, 8, 1, 11, 30, tzinfo=UTC), + datetime(2026, 9, 1, 0, 30, tzinfo=UTC), ) - self.poll(datetime(2026, 8, 1, 11, 30, tzinfo=UTC), n_listed=1) + self.poll(datetime(2026, 9, 1, 0, 30, tzinfo=UTC), n_listed=1) outages, _, index = self.load() - months = ["2026-07", "2026-08"] + months = ["2026-08", "2026-09"] by_month = render.shard(outages, months, self.until) - self.assertEqual(len(by_month["2026-07"]), 1) self.assertEqual(len(by_month["2026-08"]), 1) + self.assertEqual(len(by_month["2026-09"]), 1) for ym in months: counted = model.county_month( @@ -1015,14 +1027,19 @@ def test_the_first_and_last_days_of_collection_are_short(self): self.load() self.assertEqual( model.partial_days(self.until), - [model.COLLECTION_START.date().isoformat(), "2026-08-12"], + [model.local(model.COLLECTION_START).date().isoformat(), "2026-08-12"], ) def test_a_horizon_on_the_stroke_of_midnight_leaves_a_whole_day(self): """[lo, hi) - a window ending at 00:00 covers the previous day fully.""" - until = datetime(2026, 8, 13, 0, 0, tzinfo=UTC) + until = datetime(2026, 8, 12, 23, 0, tzinfo=UTC) # Dublin's midnight self.assertEqual(model.partial_days(until)[-1], "2026-08-12") + def test_the_last_day_is_dublins(self): + # 23:30 UTC on the 12th is half past midnight on the 13th in Dublin + until = datetime(2026, 8, 12, 23, 30, tzinfo=UTC) + self.assertEqual(model.partial_days(until)[-1], "2026-08-13") + class TestMonthList(unittest.TestCase): """A month reaches the strip on its first day, not on its first evening.""" @@ -1039,6 +1056,13 @@ def test_the_new_month_is_listed_from_its_first_instant(self): ["2026-07", "2026-08", "2026-09"], ) + def test_the_month_turns_over_at_dublins_midnight(self): + # 23:30 UTC on 31 August is already September in Dublin + self.assertEqual( + model.month_list(model.COLLECTION_START, datetime(2026, 8, 31, 23, 30, tzinfo=UTC))[-1], + "2026-09", + ) + def test_the_first_build_of_the_month_already_has_it(self): self.assertEqual( model.month_list(model.COLLECTION_START, self.FIRST_BUILD_OF_SEPTEMBER)[-1], @@ -1262,7 +1286,8 @@ class TestCaseCopy(unittest.TestCase): Records are hand-made in case_record's shape: [id, location, planned, customers, start, end, endSrc, reason, chain, - updates, est, ongoing]. + updates, est, ongoing]. Their times are UTC and the row prints Dublin's, + an hour ahead through the summer. """ # The horizon every row here is read against, well past each record's end @@ -1289,8 +1314,8 @@ def record(**over): def test_a_confirmed_restore_says_how_long_and_how_it_landed(self): html = self.html(self.record()) self.assertIn( - "17 customers affected · began Mon 24 Aug, 10:46 · " - "restored 14:32 (3 h 46 min) · 28 min earlier than ESB estimated", + "17 customers affected · began Mon 24 Aug, 11:46 · " + "restored 15:32 (3 h 46 min) · 28 min earlier than ESB estimated", html, ) # the duration belongs to the phrase naming the end it measures @@ -1298,18 +1323,35 @@ def test_a_confirmed_restore_says_how_long_and_how_it_landed(self): def test_a_restore_past_the_estimate_says_later(self): html = self.html(self.record(est="2026-08-24T13:00")) - self.assertIn("restored 14:32 (3 h 46 min) · 1 h 32 min later than ESB estimated", html) + self.assertIn("restored 15:32 (3 h 46 min) · 1 h 32 min later than ESB estimated", html) def test_an_estimate_all_but_met_is_not_worth_a_clause(self): # Inside five minutes either way, "3 min earlier" is noise dressed as # a finding. 5% of restored faults land there. html = self.html(self.record(est="2026-08-24T14:35")) - self.assertIn("restored 14:32 (3 h 46 min)", html) + self.assertIn("restored 15:32 (3 h 46 min)", html) self.assertNotIn("than ESB estimated", html) def test_an_end_on_a_later_day_names_the_day(self): html = self.html(self.record(end="2026-08-25T01:10", est=None)) - self.assertIn("restored Tue 25 Aug, 01:10", html) + self.assertIn("restored Tue 25 Aug, 02:10", html) + + def test_the_day_is_dublins_not_utcs(self): + # 23:30 UTC is 00:30 the next morning in Dublin in August. + html = self.html(self.record(end="2026-08-24T23:30", est=None)) + self.assertIn("restored Tue 25 Aug, 00:30", html) + + def test_winter_times_are_utc_already(self): + html = self.html(self.record(start="2026-12-01T10:46", end="2026-12-01T14:32", + est=None)) + self.assertIn("began Tue 1 Dec, 10:46 · restored 14:32 (3 h 46 min)", html) + + def test_a_span_across_the_clock_change_is_the_real_one(self): + # Clocks go back at 02:00 on 25 October: 00:30 to 03:30 on the wall is + # four hours, and the span says so because it is taken in UTC. + html = self.html(self.record(start="2026-10-24T23:30", end="2026-10-25T03:30", + est=None)) + self.assertIn("began Sun 25 Oct, 00:30 · restored 03:30 (4 h)", html) def test_an_unconfirmed_fault_end_says_what_is_missing(self): # "not confirmed" left a reader guessing whether the estimate or the @@ -1318,7 +1360,7 @@ def test_an_unconfirmed_fault_end_says_what_is_missing(self): self.record(end="2026-08-24T15:00", end_src="estimated", est=None) ) self.assertIn( - "expected back by 15:00 (about 4 h) · no restore time published", html + "expected back by 16:00 (about 4 h) · no restore time published", html ) self.assertNotIn("not confirmed", html) @@ -1366,7 +1408,7 @@ def test_planned_works_read_as_a_schedule_not_a_failed_promise(self): self.record(planned=1, end="2026-08-24T15:00", end_src="estimated", est=None) ) - self.assertIn("scheduled until 15:00 (4 h 14 min)", html) + self.assertIn("scheduled until 16:00 (4 h 14 min)", html) self.assertNotIn("not confirmed", html) # Still listed at the last poll: the delisted wording read as an ending. @@ -1384,15 +1426,15 @@ def test_a_live_fault_with_an_estimate_ahead_says_when(self): self.record(start="2026-09-05T03:25", end="2026-09-05T05:00", end_src="listed", est="2026-09-05T07:30", ongoing=1) ) - self.assertIn("still out when last checked · expected back by 07:30", html) + self.assertIn("still out when last checked · expected back by 08:30", html) def test_an_estimate_between_the_sighting_and_the_horizon_has_passed(self): - # Last seen 04:30, estimate 04:45, data to 05:00: the time has passed. + # Last seen 04:30 UTC, estimate 04:45, data to 05:00: the time has passed. html = self.html( self.record(start="2026-09-05T03:25", end="2026-09-05T04:30", end_src="listed", est="2026-09-05T04:45", ongoing=1) ) - self.assertIn("past ESB's estimate of 04:45", html) + self.assertIn("past ESB's estimate of 05:45", html) self.assertNotIn("expected back", html) def test_a_live_fault_past_its_estimate_says_so(self): @@ -1402,7 +1444,7 @@ def test_a_live_fault_past_its_estimate_says_so(self): end_src="estimated", est="2026-09-05T00:15", ongoing=1) ) self.assertIn( - "still out when last checked · past ESB's estimate of Sat 5 Sep, 00:15", + "still out when last checked · past ESB's estimate of Sat 5 Sep, 01:15", html, ) self.assertNotIn("expected back", html) @@ -1414,7 +1456,7 @@ def test_live_planned_works_keep_their_schedule(self): end_src="listed", est="2026-09-09T17:00", ongoing=1) ) self.assertIn( - "scheduled until Wed 9 Sep, 17:00 (7 days) · still listed when last checked", + "scheduled until Wed 9 Sep, 18:00 (7 days) · still listed when last checked", html, ) self.assertNotIn("listed for", html) @@ -1469,11 +1511,12 @@ class TestTheFiveDayGate(unittest.TestCase): def test_it_measures_calendar_coverage_not_days_with_faults(self): """Nothing in it counts an outage. A quiet county and a battered one clear it on the same date.""" + # Dublin's midnight on the 6th, which is 23:00 UTC on the 5th self.assertIsNotNone( - model.days_gate("2026-09", datetime(2026, 9, 5, 23, 59, tzinfo=UTC)) + model.days_gate("2026-09", datetime(2026, 9, 5, 22, 59, tzinfo=UTC)) ) self.assertIsNone( - model.days_gate("2026-09", datetime(2026, 9, 6, 0, 0, tzinfo=UTC)) + model.days_gate("2026-09", datetime(2026, 9, 5, 23, 0, tzinfo=UTC)) ) def test_it_opens_five_days_after_the_window_not_the_month(self): @@ -1485,7 +1528,53 @@ def test_it_opens_five_days_after_the_window_not_the_month(self): ) def test_a_month_caught_too_late_never_opens(self): - """July holds three hours and is over. The caller has to check the date + """July holds two hours and is over. The caller has to check the date against the month, or it promises a grade that can never arrive.""" when = model.days_gate("2026-07", datetime(2026, 9, 1, tzinfo=UTC)) self.assertGreater(when, model.month_bounds("2026-07")[1]) + + +class TestDublinDisplay(SiteModelCase): + """Stored in UTC, cut and printed on Dublin's clock.""" + + def test_a_fault_just_after_dublins_midnight_colours_that_day(self): + # 23:10-23:50 UTC on the 12th is 00:10-00:50 on the 13th in Dublin + self.observe( + detail("1", numCustAffected=200000, outageType="Restored", + startTime="13/08/2026 00:10", restoreTime="13/08/2026 00:50"), + datetime(2026, 8, 12, 23, 30, tzinfo=UTC), + ) + self.poll(datetime(2026, 8, 14, 6, tzinfo=UTC)) + outages, _, index = self.load() + cells = model.county_month( + outages, "Dublin", index.customers["Dublin"], "2026-08", NOW, self.until + )["cells"] + self.assertEqual(cells[11], "0") + self.assertNotEqual(cells[12], "0") + + 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_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_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] + self.assertEqual(data["observed_month"], "2026-09") + self.assertEqual(data["observed"], "Tue 1 Sep, 00:30") + + def test_the_app_prints_through_the_same_conversion(self): + # the JS mirror has no runner in CI, so its call sites are held here + page = (Path(model.__file__).parent / "site.html").read_text() + + def body(name): + return page.split(f"function {name}(", 1)[1].split("\nfunction ", 1)[0] + + self.assertIn("ts = local(ts); ref = local(ref);", body("whenAt")) + self.assertIn("local(k[4])", body("caseHtml")) + self.assertIn("when(local(r[1]))", body("updateLine")) + self.assertIn('timeZone: "Europe/Dublin"', page) + self.assertIn("D.observed_month === curMonth", page) diff --git a/tests/test_site_national.py b/tests/test_site_national.py index ac9bcee..61e72a4 100644 --- a/tests/test_site_national.py +++ b/tests/test_site_national.py @@ -253,7 +253,7 @@ def test_the_payload_carries_no_per_outage_records(self): self.assertEqual( set(self.data), { - "generated", "observed", "observed_iso", "stale_hours", + "generated", "observed", "observed_iso", "observed_month", "stale_hours", "partial", "daygate", "compare", "start", "months", "esb", "counties", "customers", "stats", "national",