Show trustworthy local-day activity totals (#90)

Add local-day activity summaries derived from UTC hour observations using
the system timezone, with durable storage in a new local_days table.
The collector derives local-day read/write totals after UTC aggregation;
the TUI displays them with timezone, completeness state, and coverage.

Schema: bump SCHEMA_VERSION to 3, add local_days table (migration 2→3
is pure addition, idempotent, preserves newer-schema refusal).
This commit is contained in:
xavierk
2026-09-18 12:28:23 +05:30
parent 366c2f55b7
commit 4f884b4b73
8 changed files with 834 additions and 3 deletions
+14
View File
@@ -345,6 +345,20 @@ def run_collection(
from .day_aggregate import derive_all_days, persist_day_aggregate
for agg in derive_all_days(conn):
persist_day_aggregate(conn, agg)
# Derive local-day summary using system timezone (issue #90)
try:
from .tz_util import detect_system_tz
from .local_day import derive_local_day_summary, persist_local_day
tz_name = detect_system_tz()
clock_now = clock.utcnow()
local_summary = derive_local_day_summary(conn, tz_name, clock_now)
if local_summary is not None:
persist_local_day(conn, local_summary)
except Exception:
# Local-day derivation failure must not prevent publication
pass
conn.commit()
except Exception:
# Derivation failure must not prevent sample persistence (issue #73 AC6)
+230
View File
@@ -0,0 +1,230 @@
"""Local-day activity derivation from UTC hour observations.
Computes durable local-day read/write summaries using the actual local
midnight boundaries, retaining UTC hour/day aggregates for endurance
projections. The collector owns this derivation, preserving the
existing read-only TUI boundary (ADR 0010).
Key contracts:
- An interval wholly attributable to a local day contributes its volume once
- Midnight-spanning intervals are retained once as shared/unallocated evidence
- UTC hour/day aggregates are never modified or deleted
- Migration cannot manufacture local precision from historical UTC data
"""
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@dataclass(frozen=True)
class LocalDaySummary:
"""One local day's aggregated read/write volumes."""
local_date: str # e.g. "2026-09-01"
tz_name: str # e.g. "Asia/Kolkata"
tz_offset: str # e.g. "+05:30"
utc_start: str # ISO 8601 UTC: the local midnight that starts this day
utc_end: str # ISO 8601 UTC: the local midnight that ends this day
bytes_written: int
bytes_read: int
coverage: float # known / (known + unknown) in UTC hours
sample_count: int
complete: bool # day's UTC range fully covered by hour observations
def _local_midnight_utc(dt: datetime, tz_name: str) -> datetime:
"""Compute the UTC time of the local midnight that contains *dt*.
Returns the most recent local midnight in UTC. For example, if *dt*
is 2026-09-01T17:35:00+05:30 (i.e. 12:05 UTC) this returns
2026-08-31T18:30:00+00:00 (2026-09-01 00:00 in +05:30).
"""
from zoneinfo import ZoneInfo
local_tz = ZoneInfo(tz_name)
local_dt = dt.astimezone(local_tz)
local_midnight = local_dt.replace(hour=0, minute=0, second=0, microsecond=0)
return local_midnight.astimezone(timezone.utc)
def derive_local_day_summary(
conn: sqlite3.Connection,
tz_name: str,
clock_now: datetime,
) -> LocalDaySummary | None:
"""Derive a local-day summary from UTC hour observations.
Computes the UTC boundaries of the current local day, queries the
UTC hours overlapping that range, and aggregates read/write volumes.
Midnight-spanning hours are retained once as shared evidence.
Returns None if no hours exist for the local day.
"""
from zoneinfo import ZoneInfo
from .tz_util import get_tz_offset_str
local_tz = ZoneInfo(tz_name)
local_dt = clock_now.astimezone(local_tz)
local_date = local_dt.strftime("%Y-%m-%d")
tz_offset_str = get_tz_offset_str(clock_now, tz_name)
# UTC boundaries of this local day
utc_start = _local_midnight_utc(clock_now, tz_name)
next_local = local_dt + timedelta(days=1)
utc_end = next_local.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
utc_start_iso = utc_start.isoformat()
utc_end_iso = utc_end.isoformat()
# Query UTC hours overlapping the local day
cursor = conn.execute(
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count, "
" active_seconds, idle_seconds, powered_off_seconds, unknown_seconds "
"FROM hour_observations "
"WHERE hour >= ? AND hour < ? "
"ORDER BY hour",
(utc_start_iso, utc_end_iso),
)
rows = cursor.fetchall()
# Also check for a midnight-spanning hour before utc_start
# (UTC hour that starts before utc_start but ends after it)
# The relevant hour is the one whose floor-hour contains utc_start
midnight_hour = utc_start.replace(minute=0, second=0, microsecond=0)
midnight_hour_iso = midnight_hour.strftime("%Y-%m-%dT%H:00:00+00:00")
prev_row = conn.execute(
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count "
"FROM hour_observations WHERE hour = ?",
(midnight_hour_iso,),
).fetchone()
total_bw = 0
total_br = 0
total_samples = 0
known_seconds = 0
unknown_seconds = 0
hour_count = 0
for row in rows:
total_bw += row[1] or 0
total_br += row[2] or 0
total_samples += row[3] or 0
known_seconds += (row[4] or 0) + (row[5] or 0) + (row[6] or 0)
unknown_seconds += row[7] or 0
hour_count += 1
# Handle midnight-spanning hour (starts before local midnight, ends after)
if prev_row is not None:
# This hour straddles the local midnight boundary
total_bw += prev_row[1] or 0
total_br += prev_row[2] or 0
total_samples += prev_row[3] or 0
hour_count += 1
total_evidenced = known_seconds + unknown_seconds
# Coverage is known seconds as a share of the full local day,
# not just the hours present — gaps reduce coverage
day_seconds = int((utc_end - utc_start).total_seconds())
coverage = known_seconds / day_seconds if day_seconds > 0 else 0.0
# Day is complete only if known (usable) seconds cover the full local day.
# Unknown seconds represent gaps without usable observation evidence.
complete = known_seconds >= day_seconds and hour_count > 0
# Return None if no hours exist for this local day
if hour_count == 0:
return None
return LocalDaySummary(
local_date=local_date,
tz_name=tz_name,
tz_offset=tz_offset_str,
utc_start=utc_start_iso,
utc_end=utc_end_iso,
bytes_written=total_bw,
bytes_read=total_br,
coverage=coverage,
sample_count=total_samples,
complete=complete,
)
def persist_local_day(
conn: sqlite3.Connection,
summary: LocalDaySummary,
) -> bool:
"""Upsert a local-day summary. Returns True if newly created."""
existing = conn.execute(
"SELECT id FROM local_days WHERE local_date = ? AND tz_name = ?",
(summary.local_date, summary.tz_name),
).fetchone()
if existing is None:
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(summary.local_date, summary.tz_name, summary.tz_offset,
summary.utc_start, summary.utc_end,
summary.bytes_written, summary.bytes_read,
summary.coverage, summary.sample_count, summary.complete),
)
created = True
else:
conn.execute(
"UPDATE local_days "
"SET tz_offset = ?, utc_start = ?, utc_end = ?, "
" bytes_written = ?, bytes_read = ?, "
" coverage = ?, sample_count = ?, complete = ? "
"WHERE id = ?",
(summary.tz_offset, summary.utc_start, summary.utc_end,
summary.bytes_written, summary.bytes_read,
summary.coverage, summary.sample_count, summary.complete,
existing[0]),
)
created = False
# Caller manages transactions (collector commits once after all derivation)
return created
def query_local_day_summary(
conn: sqlite3.Connection,
local_date: str,
) -> dict | None:
"""Query a stored local-day summary by date.
Returns a dict with the summary fields, or None if no entry exists.
"""
row = conn.execute(
"SELECT local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete "
"FROM local_days WHERE local_date = ? "
"ORDER BY id DESC LIMIT 1",
(local_date,),
).fetchone()
if row is None:
return None
return {
"local_date": row[0],
"tz_name": row[1],
"tz_offset": row[2],
"utc_start": row[3],
"utc_end": row[4],
"bytes_written": row[5],
"bytes_read": row[6],
"coverage": row[7],
"sample_count": row[8],
"complete": row[9],
}
def query_current_local_day(
conn: sqlite3.Connection,
clock_now: datetime,
tz_name: str,
) -> dict | None:
"""Query the local-day summary for the current local date."""
from zoneinfo import ZoneInfo
local_tz = ZoneInfo(tz_name)
local_dt = clock_now.astimezone(local_tz)
local_date = local_dt.strftime("%Y-%m-%d")
return query_local_day_summary(conn, local_date)
+46 -1
View File
@@ -12,7 +12,7 @@ from typing import Optional
# Schema version - increment on each migration
SCHEMA_VERSION = 2
SCHEMA_VERSION = 3
# Packaged default placement (spec §8.3). The config may override it, but a
@@ -193,6 +193,26 @@ def _create_schema(conn: sqlite3.Connection):
""")
# Local-day activity summaries derived from UTC hour observations.
# Each row retains its recorded timezone and UTC boundaries so that
# historical summaries survive a system-timezone change (ADR 0010).
conn.execute("""
CREATE TABLE IF NOT EXISTS local_days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local_date TEXT NOT NULL, -- e.g. "2026-09-01" in the recorded tz
tz_name TEXT NOT NULL, -- POSIX tz name, e.g. "Asia/Kolkata"
tz_offset TEXT NOT NULL, -- e.g. "+05:30"
utc_start TEXT NOT NULL, -- ISO 8601 UTC: local midnight start
utc_end TEXT NOT NULL, -- ISO 8601 UTC: local midnight end
bytes_written INTEGER DEFAULT 0,
bytes_read INTEGER DEFAULT 0,
coverage REAL DEFAULT 0.0,
sample_count INTEGER DEFAULT 0,
complete BOOLEAN DEFAULT 0,
UNIQUE(local_date, tz_name)
)
""")
# Metadata table for store state (e.g., legacy import marker)
conn.execute("""
CREATE TABLE IF NOT EXISTS store_metadata (
@@ -230,6 +250,31 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
current_version = 2
# Migration 2→3: add local_days table for local-day activity totals
# (issue #90, ADR 0010). Pure addition — no existing rows touched.
if current_version < 3:
tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "local_days" not in tables:
conn.execute("""
CREATE TABLE IF NOT EXISTS local_days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local_date TEXT NOT NULL,
tz_name TEXT NOT NULL,
tz_offset TEXT NOT NULL,
utc_start TEXT NOT NULL,
utc_end TEXT NOT NULL,
bytes_written INTEGER DEFAULT 0,
bytes_read INTEGER DEFAULT 0,
coverage REAL DEFAULT 0.0,
sample_count INTEGER DEFAULT 0,
complete BOOLEAN DEFAULT 0,
UNIQUE(local_date, tz_name)
)
""")
current_version = 3
def migrate_to_latest(store_path: Path) -> int:
"""Apply forward-only migrations to bring the store to SCHEMA_VERSION.
+74
View File
@@ -1045,19 +1045,35 @@ class FenrisTuiApp(App):
grid-rows: auto 10 auto 3;
height: auto;
}
#main-grid.local-day {
grid-size: 2 5;
grid-rows: auto 10 auto auto 3;
}
#main-grid.paused {
grid-size: 2 5;
grid-rows: auto auto 10 auto 3;
}
#main-grid.paused.local-day {
grid-size: 2 6;
grid-rows: auto auto 10 auto auto 3;
}
#main-grid.constrained {
grid-size: 1 4;
grid-columns: 1fr;
grid-rows: auto auto auto auto;
}
#main-grid.constrained.local-day {
grid-size: 1 5;
grid-rows: auto auto auto auto auto;
}
#main-grid.constrained.paused {
grid-size: 1 5;
grid-rows: auto auto auto auto auto;
}
#main-grid.constrained.paused.local-day {
grid-size: 1 6;
grid-rows: auto auto auto auto auto auto;
}
#main-grid.constrained #usage-history {
display: none;
}
@@ -1071,6 +1087,14 @@ class FenrisTuiApp(App):
height: auto;
min-height: 3;
}
#local-day {
column-span: 2;
display: none;
border: round #555555;
padding: 0 1;
height: auto;
min-height: 3;
}
#dashboard-scroll { height: 1fr; }
#headline-band { column-span: 2; height: auto; min-height: 8; }
#paused-banner {
@@ -1141,6 +1165,7 @@ class FenrisTuiApp(App):
yield DailyBarGraph(id="usage-history", classes="pane")
yield Static("", id="constrained-summary", classes="pane")
yield Static("", id="drive-health", classes="pane")
yield Static("", id="local-day", classes="pane")
yield Static("", id="service-strip", classes="pane")
yield Static("q Quit TUI", id="quit-rail")
@@ -1154,6 +1179,7 @@ class FenrisTuiApp(App):
self.query_one("#usage-history").border_title = "Usage history"
self.query_one("#constrained-summary").border_title = "Usage history"
self.query_one("#drive-health").border_title = "Drive"
self.query_one("#local-day").border_title = "Local day"
self.query_one("#service-strip").border_title = "Monitoring and actions"
self._refresh_timer = self.set_interval(
self.refresh_interval_s, self.on_refresh_tick
@@ -1277,6 +1303,11 @@ class FenrisTuiApp(App):
self.query_one("#constrained-summary").update(
"[dim]Graph needs ≥80×24[/dim]\n" + message
)
# Hide local-day widget when no data
local_day_widget = self.query_one("#local-day")
local_day_widget.styles.display = "none"
main_grid = self.query_one("#main-grid")
main_grid.remove_class("local-day")
def _render_all_regions(self, conn: sqlite3.Connection, comp: StatusComposition) -> None:
"""Render all four regions from live store data."""
@@ -1355,6 +1386,9 @@ class FenrisTuiApp(App):
)
self.query_one("#drive-health").update(health_text)
# --- Local-day totals (issue #90) ---
self._render_local_day(conn)
def _render_paused_banner(self, comp) -> None:
"""Show the high-contrast Deliberate disable block only when sanctioned.
@@ -1388,6 +1422,46 @@ class FenrisTuiApp(App):
main_grid.remove_class("paused")
main_grid.refresh(layout=True)
def _render_local_day(self, conn: sqlite3.Connection) -> None:
"""Render the local-day activity totals widget (issue #90)."""
try:
from .tz_util import detect_system_tz
from .local_day import query_current_local_day
tz_name = detect_system_tz()
local = query_current_local_day(conn, self._clock_now, tz_name)
except Exception:
local = None
widget = self.query_one("#local-day")
main_grid = self.query_one("#main-grid")
if local is None:
widget.styles.display = "none"
main_grid.remove_class("local-day")
return
main_grid.add_class("local-day")
widget.styles.display = "block"
bw = local["bytes_written"]
br = local["bytes_read"]
partial = "" if local["complete"] else " · totals so far"
tz_display = "%s %s" % (local["tz_name"], local["tz_offset"])
text = (
"[bold]%s[/bold] %s%s\n"
" W %.3f GB · R %.3f GB · %.0f%% coverage"
% (
local["local_date"],
tz_display,
partial,
bw / 1e9,
br / 1e9,
local["coverage"] * 100,
)
)
widget.update(text)
def _on_graph_drill(self, day: str) -> None:
"""Load hourly data when the graph enters drill-down mode."""
graph = self.query_one("#usage-history")
+49
View File
@@ -0,0 +1,49 @@
"""System timezone detection for local-day activity totals.
Provides timezone detection (TZ env, /etc/localtime) and offset
computation used by local-day derivation. All functions are
stateless and safe to call from the collector and TUI reader.
"""
import os
from datetime import datetime, timezone
from pathlib import Path
def detect_system_tz() -> str:
"""Detect the system timezone name.
Resolution order:
1. ``TZ`` environment variable (may be empty or ":UTC")
2. Symlink target of ``/etc/localtime``
3. Falls back to ``"UTC"``
Returns a POSIX timezone name like ``"Asia/Kolkata"`` or ``"UTC"``.
"""
tz = os.environ.get("TZ", "").strip()
if tz:
return tz.lstrip(":")
localtime = Path("/etc/localtime")
if localtime.is_symlink():
target = os.readlink(str(localtime))
# Strip common prefixes: /usr/share/zoneinfo/, /usr/lib/zoneinfo/
for prefix in ("/usr/share/zoneinfo/", "/usr/lib/zoneinfo/"):
if target.startswith(prefix):
return target[len(prefix):]
return target
return "UTC"
def get_tz_offset_str(dt: datetime, tz_name: str) -> str:
"""UTC offset as ``+HH:MM`` or ``-HH:MM`` string for *dt* in *tz_name*."""
from zoneinfo import ZoneInfo
local_tz = ZoneInfo(tz_name)
local_dt = dt.astimezone(local_tz)
offset = local_dt.utcoffset()
total_seconds = int(offset.total_seconds())
sign = "+" if total_seconds >= 0 else "-"
total_seconds = abs(total_seconds)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
return "%s%02d:%02d" % (sign, hours, minutes)