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'
', 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",