Preserve historical activity selection (#103)
This commit is contained in:
+504
-111
@@ -13,9 +13,10 @@ from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import App, ComposeResult
|
||||
@@ -29,6 +30,7 @@ from textual.widgets import Input, Static, Tab, Tabs
|
||||
from .activity_selection import (
|
||||
HISTORY_RANGE_DEFAULT,
|
||||
ActivitySelection,
|
||||
HistoryDayIdentity,
|
||||
IntervalIdentity,
|
||||
)
|
||||
from .activity_plot import VolumePoint, volume_plot
|
||||
@@ -175,7 +177,11 @@ class HistoryGraph(Widget):
|
||||
self._hour_data: List[Dict[str, Any]] = []
|
||||
self._max_bytes: int = 0
|
||||
self._hourly_selected: int = -1
|
||||
self._showing_selected_day_outside_range = False
|
||||
self._drill_unallocated_bytes: int = 0
|
||||
self._drill_unallocated_read: int = 0
|
||||
self._drill_shared_written: int = 0
|
||||
self._drill_shared_read: int = 0
|
||||
self._on_drill: Optional[Callable[[str], None]] = None
|
||||
self._point_columns: list[int] = []
|
||||
self._empty_message = "Awaiting first sample"
|
||||
@@ -214,45 +220,50 @@ class HistoryGraph(Widget):
|
||||
"""Update graph with day data. on_drill(day) called on drill entry."""
|
||||
self._empty_message = empty_message
|
||||
selected_day = selected_date or self.selection.browse_date
|
||||
if selected_day is None and 0 <= self.selected_index < len(self._day_data):
|
||||
selected_day = self._day_data[self.selected_index].get("day")
|
||||
self._all_day_data = day_data
|
||||
self._on_drill = on_drill
|
||||
self._trim_to_range()
|
||||
self.view_mode = "daily"
|
||||
self.drill_day = None
|
||||
self._hour_data = []
|
||||
self._hourly_selected = -1
|
||||
self.view_mode = "hourly" if self.selection.view == "day" else "daily"
|
||||
self.drill_day = selected_day if self.view_mode == "hourly" else None
|
||||
if self._day_data:
|
||||
matching = next(
|
||||
(i for i, item in enumerate(self._day_data)
|
||||
if item.get("day") == selected_day),
|
||||
None,
|
||||
)
|
||||
self.selected_index = matching if matching is not None else len(self._day_data) - 1
|
||||
if self.selection.view != "live":
|
||||
self.selection.select_history_date(
|
||||
self._day_data[self.selected_index].get("day")
|
||||
if selected_day is None:
|
||||
self.selected_index = len(self._day_data) - 1
|
||||
else:
|
||||
identity = self.selection.selected_history_day
|
||||
matching = next(
|
||||
(i for i, item in enumerate(self._day_data)
|
||||
if item.get("day") == selected_day
|
||||
and (identity is None or item.get("identity") == identity)),
|
||||
None,
|
||||
)
|
||||
self.selected_index = matching if matching is not None else -1
|
||||
if matching is not None and identity is None:
|
||||
self.selection.select_history_date(
|
||||
selected_day, self._day_data[matching].get("identity"),
|
||||
)
|
||||
else:
|
||||
self.selected_index = -1
|
||||
self._refresh()
|
||||
if self.view_mode == "daily":
|
||||
self._refresh()
|
||||
else:
|
||||
self._render_range()
|
||||
|
||||
def set_hour_data(self, hour_data: List[Dict[str, Any]]) -> None:
|
||||
"""Set hourly data for drill-down view."""
|
||||
selected_hour = self.selection.selected_history_hour
|
||||
selected_hour = selected_hour or self._selected_history_hour()
|
||||
self._hour_data = hour_data
|
||||
matching = next(
|
||||
(i for i, item in enumerate(hour_data) if item.get("hour") == selected_hour),
|
||||
None,
|
||||
)
|
||||
if matching is not None:
|
||||
self._hourly_selected = matching
|
||||
else:
|
||||
matching = next((
|
||||
i for i, item in enumerate(hour_data)
|
||||
if item.get("hour") == selected_hour
|
||||
), None) if selected_hour is not None else None
|
||||
if selected_hour is not None:
|
||||
self._hourly_selected = matching if matching is not None else -1
|
||||
elif hour_data:
|
||||
selectable = [i for i, item in enumerate(hour_data) if not item.get("is_future")]
|
||||
self._hourly_selected = selectable[-1] if selectable else -1
|
||||
self.selection.select_history_hour(self._selected_history_hour())
|
||||
self.selection.select_history_hour(self._selected_history_hour())
|
||||
else:
|
||||
self._hourly_selected = -1
|
||||
self._refresh_hourly()
|
||||
|
||||
def _trim_to_range(self) -> None:
|
||||
@@ -261,13 +272,41 @@ class HistoryGraph(Widget):
|
||||
if len(self._all_day_data) > self.range_days
|
||||
else list(self._all_day_data)
|
||||
)
|
||||
self._showing_selected_day_outside_range = False
|
||||
selected_date = self.selection.browse_date
|
||||
if selected_date and not any(
|
||||
item.get("day") == selected_date for item in self._day_data
|
||||
):
|
||||
identity = self.selection.selected_history_day
|
||||
selected = next(
|
||||
(item for item in self._all_day_data
|
||||
if item.get("day") == selected_date
|
||||
and (identity is None or item.get("identity") == identity)),
|
||||
None,
|
||||
)
|
||||
if selected is not None:
|
||||
self._day_data = [selected]
|
||||
self._showing_selected_day_outside_range = True
|
||||
elif identity is not None:
|
||||
self._day_data = []
|
||||
self._max_bytes = max(
|
||||
(d.get("total_bytes", 0) for d in self._day_data), default=0
|
||||
(d.get("total_bytes") or 0 for d in self._day_data), default=0
|
||||
)
|
||||
|
||||
def _refresh(self) -> None:
|
||||
if not self._day_data:
|
||||
self._show_empty()
|
||||
if self.selection.browse_date is not None:
|
||||
self.query_one("#bar-range").update(
|
||||
"Local-day history · %d days" % self.range_days
|
||||
)
|
||||
self.query_one("#bar-render").update(self._empty_message)
|
||||
self.query_one("#bar-legend").update("")
|
||||
self.query_one("#bar-readout").update(
|
||||
"%s · local-day evidence unavailable"
|
||||
% self.selection.browse_date
|
||||
)
|
||||
else:
|
||||
self._show_empty()
|
||||
return
|
||||
if self._is_constrained():
|
||||
self._show_constrained_summary()
|
||||
@@ -311,9 +350,16 @@ class HistoryGraph(Widget):
|
||||
return
|
||||
|
||||
# Summarise visible days as text
|
||||
total_written = sum(d.get("total_written", d.get("total_bytes", 0)) for d in self._day_data)
|
||||
total_read = sum(d.get("total_read", 0) for d in self._day_data)
|
||||
days_with_data = sum(1 for d in self._day_data if d.get("total_bytes", 0) > 0 or d.get("total_read", 0) > 0)
|
||||
total_written = sum(
|
||||
(d.get("total_written", d.get("total_bytes", 0)) or 0)
|
||||
for d in self._day_data
|
||||
)
|
||||
total_read = sum((d.get("total_read", 0) or 0) for d in self._day_data)
|
||||
days_with_data = sum(
|
||||
1 for d in self._day_data
|
||||
if (d.get("total_bytes", 0) or 0) > 0
|
||||
or (d.get("total_read", 0) or 0) > 0
|
||||
)
|
||||
n = len(self._day_data)
|
||||
first = self._day_data[0].get("local_label", "")
|
||||
last = self._day_data[-1].get("local_label", "")
|
||||
@@ -352,6 +398,15 @@ class HistoryGraph(Widget):
|
||||
self._refresh_hourly()
|
||||
|
||||
def _render_range(self) -> None:
|
||||
if self._showing_selected_day_outside_range and self._day_data:
|
||||
selected = self._day_data[0]
|
||||
self.query_one("#bar-range").update(
|
||||
"Selected day · [bold]%s[/bold] · %s" % (
|
||||
selected.get("day", ""),
|
||||
selected.get("timezone_label", "local-day evidence unavailable"),
|
||||
)
|
||||
)
|
||||
return
|
||||
parts = []
|
||||
for key, r in enumerate(_RANGE_OPTIONS, start=1):
|
||||
label = "%d: %dd" % (key, r)
|
||||
@@ -359,20 +414,38 @@ class HistoryGraph(Widget):
|
||||
parts.append("[reverse bold]\\[%s][/reverse bold]" % label)
|
||||
else:
|
||||
parts.append("[%s]" % label)
|
||||
label = "%d days · UTC %s" % (
|
||||
label = "%d local days %s" % (
|
||||
self.range_days, " ".join(parts),
|
||||
)
|
||||
if self.view_mode == "hourly":
|
||||
label = "Hourly · UTC · [bold]%s[/bold] · Backspace History" % (
|
||||
selected_day = next((
|
||||
item for item in self._all_day_data
|
||||
if item.get("day") == self.drill_day
|
||||
and (self.selection.selected_history_day is None
|
||||
or item.get("identity") == self.selection.selected_history_day)
|
||||
), None)
|
||||
timezone_label = selected_day.get("timezone_label", "") if selected_day else ""
|
||||
label = "Hourly · [bold]%s[/bold] · %s · Backspace History" % (
|
||||
self.drill_day or "",
|
||||
timezone_label,
|
||||
)
|
||||
self.query_one("#bar-range").update(label)
|
||||
|
||||
def _render_bars(self) -> None:
|
||||
points = []
|
||||
for index, day in enumerate(self._day_data):
|
||||
volume = day.get("total_read", 0) if self.measure == "read" else day.get("total_bytes", 0)
|
||||
unallocated = day.get("unallocated_read" if self.measure == "read" else "unallocated_bytes", 0)
|
||||
if "activity_state" in day:
|
||||
volume = day.get("total_read") if self.measure == "read" else day.get("total_bytes")
|
||||
unallocated = day.get(
|
||||
"shared_bytes_read" if self.measure == "read" else "shared_bytes_written",
|
||||
0,
|
||||
)
|
||||
else:
|
||||
volume = day.get("total_read", 0) if self.measure == "read" else day.get("total_bytes", 0)
|
||||
unallocated = day.get(
|
||||
"unallocated_read" if self.measure == "read" else "unallocated_bytes",
|
||||
0,
|
||||
)
|
||||
state = "unallocated" if unallocated else "gap" if day.get("is_gap") else (
|
||||
"partial" if day.get("is_partial") else "measured"
|
||||
)
|
||||
@@ -395,10 +468,48 @@ class HistoryGraph(Widget):
|
||||
|
||||
def _render_readout(self) -> None:
|
||||
if self.selected_index < 0 or self.selected_index >= len(self._day_data):
|
||||
self.query_one("#bar-readout").update("[dim]No selectable day[/dim]")
|
||||
date = self.selection.browse_date
|
||||
self.query_one("#bar-readout").update(
|
||||
("%s · local-day evidence unavailable" % date)
|
||||
if date else "[dim]No selectable day[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
day = self._day_data[self.selected_index]
|
||||
if "activity_state" in day:
|
||||
if day.get("is_gap"):
|
||||
self.query_one("#bar-readout").update(
|
||||
"%s · local-day evidence unavailable"
|
||||
% day.get("day", "")
|
||||
)
|
||||
return
|
||||
written = day.get("total_written")
|
||||
read = day.get("total_read")
|
||||
written_text = "unavailable" if written is None else "%.3f GB known" % (written / 1e9)
|
||||
read_text = "unavailable" if read is None else "%.3f GB known" % (read / 1e9)
|
||||
parts = [
|
||||
"[bold]%s[/bold] · %s · %s"
|
||||
% (day.get("day", ""), day.get("timezone_label", ""),
|
||||
day.get("activity_state", "incomplete")),
|
||||
"W %s · R %s" % (written_text, read_text),
|
||||
]
|
||||
shared_written = day.get("shared_bytes_written", 0)
|
||||
shared_read = day.get("shared_bytes_read", 0)
|
||||
if shared_written or shared_read:
|
||||
parts.append(
|
||||
"shared at midnight W %.3f GB · R %.3f GB"
|
||||
% (shared_written / 1e9, shared_read / 1e9)
|
||||
)
|
||||
unallocated_written = day.get("unallocated_bytes_written", 0)
|
||||
unallocated_read = day.get("unallocated_bytes_read", 0)
|
||||
if unallocated_written or unallocated_read:
|
||||
parts.append(
|
||||
"unallocated W %.3f GB · R %.3f GB"
|
||||
% (unallocated_written / 1e9, unallocated_read / 1e9)
|
||||
)
|
||||
self.query_one("#bar-readout").update("\n".join(parts))
|
||||
return
|
||||
|
||||
if day.get("is_gap"):
|
||||
# Known daily evidence survives missing hourly coverage; absent
|
||||
# evidence must not be presented as a measured zero.
|
||||
@@ -452,7 +563,15 @@ class HistoryGraph(Widget):
|
||||
if not self._hour_data:
|
||||
self.query_one("#bar-render").update("[dim]No hourly data[/dim]")
|
||||
self.query_one("#bar-legend").update("")
|
||||
self.query_one("#bar-readout").update("")
|
||||
if self.selection.selected_history_hour is not None:
|
||||
readout = "%s · selected hour unavailable" % (
|
||||
self.selection.selected_history_hour,
|
||||
)
|
||||
elif self.drill_day:
|
||||
readout = "%s · hourly evidence unavailable" % self.drill_day
|
||||
else:
|
||||
readout = ""
|
||||
self.query_one("#bar-readout").update(readout)
|
||||
return
|
||||
|
||||
self._render_range()
|
||||
@@ -467,8 +586,11 @@ class HistoryGraph(Widget):
|
||||
"gap" if hour.get("is_gap") else "partial" if hour.get("is_partial") else "measured"
|
||||
)
|
||||
volume = hour.get("bytes_read" if self.measure == "read" else "bytes_written", 0)
|
||||
local_label = hour.get("local_label", "")
|
||||
if not hour.get("timezone_label"):
|
||||
local_label += ":00"
|
||||
points.append(VolumePoint(index, None if state in ("future", "gap") else volume,
|
||||
hour.get("local_label", "") + ":00", state))
|
||||
local_label, state))
|
||||
self._plot(points, self._hourly_selected)
|
||||
|
||||
self._render_hourly_readout()
|
||||
@@ -476,6 +598,43 @@ class HistoryGraph(Widget):
|
||||
def _render_hourly_readout(self) -> None:
|
||||
if 0 <= self._hourly_selected < len(self._hour_data):
|
||||
h = self._hour_data[self._hourly_selected]
|
||||
if h.get("timezone_label"):
|
||||
state = "future" if h.get("is_future") else (
|
||||
"gap" if h.get("is_gap") else
|
||||
"partial" if h.get("is_partial") else "measured"
|
||||
)
|
||||
note = ""
|
||||
if self._drill_unallocated_bytes or self._drill_unallocated_read:
|
||||
note = "\nunallocated W %.3f GB · R %.3f GB" % (
|
||||
self._drill_unallocated_bytes / 1e9,
|
||||
self._drill_unallocated_read / 1e9,
|
||||
)
|
||||
if self._drill_shared_written or self._drill_shared_read:
|
||||
note += "\nshared at midnight W %.3f GB · R %.3f GB" % (
|
||||
self._drill_shared_written / 1e9,
|
||||
self._drill_shared_read / 1e9,
|
||||
)
|
||||
if h.get("is_gap") or h.get("is_future"):
|
||||
self.query_one("#bar-readout").update(
|
||||
"%s %s · %s · %s\nW unavailable · R unavailable%s"
|
||||
% (h.get("local_label", ""), h.get("timezone_label", ""),
|
||||
h.get("utc_label", ""), state, note)
|
||||
)
|
||||
return
|
||||
self.query_one("#bar-readout").update(
|
||||
"[bold]%s %s[/bold] · %s · %s\n"
|
||||
"W %.3f GB · R %.3f GB · %d%% coverage · %s%s"
|
||||
% (
|
||||
h.get("local_label", ""), h.get("timezone_label", ""),
|
||||
h.get("utc_label", ""), self.drill_day or "",
|
||||
h.get("bytes_written", 0) / 1e9,
|
||||
h.get("bytes_read", 0) / 1e9,
|
||||
h.get("coverage", 0) * 100,
|
||||
state,
|
||||
note,
|
||||
)
|
||||
)
|
||||
return
|
||||
if h.get("is_gap") or h.get("is_future"):
|
||||
self.query_one("#bar-readout").update(
|
||||
"%s:00 UTC · %s\nW unavailable · R unavailable" % (
|
||||
@@ -499,7 +658,13 @@ class HistoryGraph(Widget):
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 Select hour[/dim]")
|
||||
if self.selection.selected_history_hour is not None:
|
||||
self.query_one("#bar-readout").update(
|
||||
"%s · selected hour unavailable"
|
||||
% self.selection.selected_history_hour
|
||||
)
|
||||
else:
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 Select hour[/dim]")
|
||||
|
||||
# -- Event handling --
|
||||
|
||||
@@ -545,8 +710,9 @@ class HistoryGraph(Widget):
|
||||
|
||||
def _notify_selection(self) -> None:
|
||||
if 0 <= self.selected_index < len(self._day_data):
|
||||
selected_day = self._day_data[self.selected_index]["day"]
|
||||
self.selection.select_history_date(selected_day)
|
||||
day = self._day_data[self.selected_index]
|
||||
selected_day = day["day"]
|
||||
self.selection.select_history_date(selected_day, day.get("identity"))
|
||||
self.post_message(self.DaySelected(selected_day))
|
||||
|
||||
def _enter_drill(self) -> None:
|
||||
@@ -554,21 +720,56 @@ class HistoryGraph(Widget):
|
||||
return
|
||||
day = self._day_data[self.selected_index]
|
||||
self.drill_day = day.get("day")
|
||||
self.selection.select_history_date(self.drill_day)
|
||||
self.selection.select_history_hour(None)
|
||||
self._drill_unallocated_bytes = day.get("unallocated_bytes", 0)
|
||||
self.selection.select_history_date(self.drill_day, day.get("identity"))
|
||||
self.selection.set_view("day")
|
||||
self._drill_unallocated_bytes = day.get("unallocated_bytes_written", 0)
|
||||
self._drill_unallocated_read = day.get("unallocated_bytes_read", 0)
|
||||
self._drill_shared_written = day.get("shared_bytes_written", 0)
|
||||
self._drill_shared_read = day.get("shared_bytes_read", 0)
|
||||
self.view_mode = "hourly"
|
||||
self._hourly_selected = -1
|
||||
self._render_range()
|
||||
if self._on_drill:
|
||||
self._on_drill(self.drill_day)
|
||||
|
||||
def show_selected_day(self) -> None:
|
||||
"""Show hourly evidence for selected date without rebuilding selection."""
|
||||
if self.selection.browse_date is None:
|
||||
if not self._day_data:
|
||||
self.selection.select_history_date(None)
|
||||
self.view_mode = "hourly"
|
||||
self.drill_day = None
|
||||
self._refresh_hourly()
|
||||
return
|
||||
selected = self._day_data[-1]
|
||||
self.selection.select_history_date(
|
||||
selected.get("day"), selected.get("identity"),
|
||||
)
|
||||
|
||||
self.drill_day = self.selection.browse_date
|
||||
selected = next((
|
||||
item for item in self._all_day_data
|
||||
if item.get("day") == self.drill_day
|
||||
and (self.selection.selected_history_day is None
|
||||
or item.get("identity") == self.selection.selected_history_day)
|
||||
), None)
|
||||
selected = selected or {}
|
||||
self._drill_unallocated_bytes = selected.get("unallocated_bytes_written", 0)
|
||||
self._drill_unallocated_read = selected.get("unallocated_bytes_read", 0)
|
||||
self._drill_shared_written = selected.get("shared_bytes_written", 0)
|
||||
self._drill_shared_read = selected.get("shared_bytes_read", 0)
|
||||
self.view_mode = "hourly"
|
||||
self._render_range()
|
||||
if self._on_drill and self.drill_day:
|
||||
self._on_drill(self.drill_day)
|
||||
else:
|
||||
self._refresh_hourly()
|
||||
|
||||
def _exit_drill(self) -> None:
|
||||
self.view_mode = "daily"
|
||||
self.drill_day = None
|
||||
self._hour_data = []
|
||||
self._hourly_selected = -1
|
||||
self.selection.select_history_hour(None)
|
||||
self._refresh()
|
||||
|
||||
def _handle_hourly_key(self, event: Any) -> None:
|
||||
@@ -580,6 +781,9 @@ class HistoryGraph(Widget):
|
||||
self.app._set_activity_view("history")
|
||||
event.stop()
|
||||
elif event.key == "left":
|
||||
if not self._hour_data:
|
||||
event.stop()
|
||||
return
|
||||
if self._hourly_selected < 0:
|
||||
self._hourly_selected = len(self._hour_data) - 1
|
||||
else:
|
||||
@@ -588,6 +792,9 @@ class HistoryGraph(Widget):
|
||||
self._refresh_hourly()
|
||||
event.stop()
|
||||
elif event.key == "right":
|
||||
if not self._hour_data:
|
||||
event.stop()
|
||||
return
|
||||
if self._hourly_selected < 0:
|
||||
self._hourly_selected = 0
|
||||
else:
|
||||
@@ -599,7 +806,9 @@ class HistoryGraph(Widget):
|
||||
event.stop()
|
||||
|
||||
def _select_history_hour(self) -> None:
|
||||
self.selection.select_history_hour(self._selected_history_hour())
|
||||
selected_hour = self._selected_history_hour()
|
||||
if selected_hour is not None:
|
||||
self.selection.select_history_hour(selected_hour)
|
||||
|
||||
def _selected_history_hour(self) -> Optional[str]:
|
||||
if 0 <= self._hourly_selected < len(self._hour_data):
|
||||
@@ -611,21 +820,29 @@ class HistoryGraph(Widget):
|
||||
|
||||
Returns True if the selection changed.
|
||||
"""
|
||||
idx = next(
|
||||
(i for i, d in enumerate(self._all_day_data)
|
||||
if d.get("day") == date_str),
|
||||
None,
|
||||
)
|
||||
if idx is None:
|
||||
return False
|
||||
|
||||
old_day = None
|
||||
if 0 <= self.selected_index < len(self._day_data):
|
||||
old_day = self.selection.browse_date
|
||||
if old_day is None and 0 <= self.selected_index < len(self._day_data):
|
||||
old_day = self._day_data[self.selected_index].get("day")
|
||||
self.selection.select_history_date(date_str)
|
||||
|
||||
identity = self.selection.selected_history_day
|
||||
matching = [
|
||||
i for i, day in enumerate(self._all_day_data)
|
||||
if day.get("day") == date_str
|
||||
and (identity is None or day.get("identity") == identity)
|
||||
]
|
||||
idx = matching[0] if matching else None
|
||||
if idx is None:
|
||||
self.selected_index = -1
|
||||
self._refresh()
|
||||
return False
|
||||
|
||||
# Count actual data days (not gaps) after the target
|
||||
n_data_after = len(self._all_day_data) - idx
|
||||
if n_data_after > self.range_days:
|
||||
if (
|
||||
idx >= len(self._all_day_data) - max(_RANGE_OPTIONS)
|
||||
and n_data_after > self.range_days
|
||||
):
|
||||
self.range_days = max(n_data_after, min(_RANGE_OPTIONS))
|
||||
|
||||
self._trim_to_range()
|
||||
@@ -633,12 +850,15 @@ class HistoryGraph(Widget):
|
||||
return False
|
||||
self.selected_index = next(
|
||||
(i for i, d in enumerate(self._day_data)
|
||||
if d.get("day") == date_str),
|
||||
idx,
|
||||
if d.get("day") == date_str
|
||||
and (identity is None or d.get("identity") == identity)),
|
||||
-1,
|
||||
)
|
||||
new_day = self._day_data[self.selected_index].get("day")
|
||||
self.selection.select_history_date(new_day)
|
||||
return new_day != old_day
|
||||
if self.selected_index >= 0:
|
||||
day = self._day_data[self.selected_index]
|
||||
self.selection.select_history_date(date_str, day.get("identity"))
|
||||
self._refresh()
|
||||
return date_str != old_day
|
||||
|
||||
def on_click(self, event: Any) -> None:
|
||||
render = self.query_one("#bar-render")
|
||||
@@ -652,9 +872,8 @@ class HistoryGraph(Widget):
|
||||
global_idx = idx
|
||||
if 0 <= global_idx < len(self._day_data):
|
||||
self.selected_index = global_idx
|
||||
self.selection.select_history_date(
|
||||
self._day_data[global_idx].get("day")
|
||||
)
|
||||
day = self._day_data[global_idx]
|
||||
self.selection.select_history_date(day.get("day"), day.get("identity"))
|
||||
self._refresh()
|
||||
self._notify_selection()
|
||||
else:
|
||||
@@ -848,6 +1067,178 @@ def _query_hourly_graph_data(
|
||||
return result
|
||||
|
||||
|
||||
def _query_local_day_graph_data(
|
||||
conn: sqlite3.Connection,
|
||||
now: datetime,
|
||||
timezone_name: str | None = None,
|
||||
days: int = max(_RANGE_OPTIONS),
|
||||
selected_date: str | None = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Read recorded local-day evidence, keeping unavailable dates visible."""
|
||||
from .local_day import query_local_day_summary
|
||||
from .tz_util import detect_system_tz
|
||||
|
||||
current_zone_name = timezone_name or detect_system_tz()
|
||||
current_zone = ZoneInfo(current_zone_name)
|
||||
end_date = now.astimezone(current_zone).date()
|
||||
start_date = end_date - timedelta(days=days - 1)
|
||||
start_text, end_text = start_date.isoformat(), end_date.isoformat()
|
||||
local_dates = {
|
||||
(start_date + timedelta(days=offset)).isoformat()
|
||||
for offset in range(days)
|
||||
}
|
||||
if selected_date is not None:
|
||||
try:
|
||||
date.fromisoformat(selected_date)
|
||||
except ValueError:
|
||||
selected_date = None
|
||||
else:
|
||||
local_dates.add(selected_date)
|
||||
rows = conn.execute(
|
||||
"SELECT local_date, tz_name FROM local_days "
|
||||
"WHERE (local_date >= ? AND local_date <= ?) "
|
||||
"OR local_date = ? ORDER BY local_date, id",
|
||||
(start_text, end_text, selected_date or ""),
|
||||
).fetchall()
|
||||
recorded_zones: Dict[str, list[str]] = {}
|
||||
for local_date, timezone in rows:
|
||||
recorded_zones.setdefault(local_date, [])
|
||||
if timezone not in recorded_zones[local_date]:
|
||||
recorded_zones[local_date].append(timezone)
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
for local_date in sorted(local_dates):
|
||||
timezones = recorded_zones.get(local_date)
|
||||
if not timezones:
|
||||
result.append({
|
||||
"day": local_date,
|
||||
"local_label": local_date,
|
||||
"timezone_label": "",
|
||||
"identity": None,
|
||||
"total_bytes": None,
|
||||
"total_written": None,
|
||||
"total_read": None,
|
||||
"shared_bytes_written": 0,
|
||||
"shared_bytes_read": 0,
|
||||
"unallocated_bytes_written": 0,
|
||||
"unallocated_bytes_read": 0,
|
||||
"activity_state": "unavailable",
|
||||
"is_zero": False,
|
||||
"is_gap": True,
|
||||
"is_partial": False,
|
||||
})
|
||||
continue
|
||||
|
||||
for timezone in timezones:
|
||||
summary = query_local_day_summary(
|
||||
conn, local_date, timezone, now,
|
||||
)
|
||||
if summary is None:
|
||||
continue
|
||||
identity = HistoryDayIdentity(
|
||||
local_date,
|
||||
summary["tz_name"],
|
||||
summary["utc_start"],
|
||||
summary["utc_end"],
|
||||
)
|
||||
written = summary["bytes_written"]
|
||||
read = summary["bytes_read"]
|
||||
state = summary["activity_state"]
|
||||
result.append({
|
||||
"day": local_date,
|
||||
"local_label": local_date,
|
||||
"timezone_label": "%s %s" % (
|
||||
summary["tz_name"], summary["tz_offset"],
|
||||
),
|
||||
"identity": identity,
|
||||
"total_bytes": written,
|
||||
"total_written": written,
|
||||
"total_read": read,
|
||||
"shared_bytes_written": summary["shared_bytes_written"],
|
||||
"shared_bytes_read": summary["shared_bytes_read"],
|
||||
"unallocated_bytes_written": summary["unallocated_bytes_written"],
|
||||
"unallocated_bytes_read": summary["unallocated_bytes_read"],
|
||||
"activity_state": state,
|
||||
"is_zero": state == "zero",
|
||||
"is_gap": written is None and read is None,
|
||||
"is_partial": state in ("incomplete", "so_far"),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _query_local_hourly_graph_data(
|
||||
conn: sqlite3.Connection,
|
||||
summary: Dict[str, Any] | None,
|
||||
now: datetime,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Read complete UTC hour buckets inside recorded local-day boundaries."""
|
||||
if summary is None:
|
||||
return []
|
||||
|
||||
zone = ZoneInfo(summary["tz_name"])
|
||||
day_start = _parse_ts(summary["utc_start"]).astimezone(timezone.utc)
|
||||
day_end = _parse_ts(summary["utc_end"]).astimezone(timezone.utc)
|
||||
current = now.astimezone(timezone.utc)
|
||||
first_hour = day_start.replace(minute=0, second=0, microsecond=0)
|
||||
if first_hour < day_start:
|
||||
first_hour += timedelta(hours=1)
|
||||
|
||||
last_hour = day_end - timedelta(hours=1)
|
||||
rows = conn.execute(
|
||||
"SELECT hour, bytes_written_delta, bytes_read_delta, coverage, "
|
||||
"sample_count, unknown_seconds FROM hour_observations "
|
||||
"WHERE hour >= ? AND hour <= ? ORDER BY hour",
|
||||
(first_hour.isoformat(), last_hour.isoformat()),
|
||||
).fetchall()
|
||||
by_hour = {
|
||||
_parse_ts(row[0]).astimezone(timezone.utc): row
|
||||
for row in rows
|
||||
}
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
hour_start = first_hour
|
||||
while hour_start + timedelta(hours=1) <= day_end:
|
||||
hour_end = hour_start + timedelta(hours=1)
|
||||
row = by_hour.get(hour_start)
|
||||
local_start = hour_start.astimezone(zone)
|
||||
local_end = hour_end.astimezone(zone)
|
||||
is_future = hour_start > current
|
||||
identity = hour_start.isoformat()
|
||||
if row is None:
|
||||
written = read = coverage = sample_count = 0
|
||||
unknown_seconds = 0
|
||||
is_gap = not is_future
|
||||
else:
|
||||
written = row[1] or 0
|
||||
read = row[2] or 0
|
||||
coverage = row[3] or 0.0
|
||||
sample_count = row[4] or 0
|
||||
unknown_seconds = row[5] or 0
|
||||
is_gap = sample_count == 0 and unknown_seconds > 0
|
||||
result.append({
|
||||
"hour": identity,
|
||||
"local_label": local_start.strftime("%H:%M %z"),
|
||||
"local_end_label": local_end.strftime("%H:%M %z"),
|
||||
"timezone_label": "%s %s" % (
|
||||
summary["tz_name"], local_start.strftime("%z"),
|
||||
),
|
||||
"utc_label": hour_start.strftime("%Y-%m-%d %H:%M UTC"),
|
||||
"bytes_written": written,
|
||||
"bytes_read": read,
|
||||
"coverage": coverage,
|
||||
"sample_count": sample_count,
|
||||
"unknown_seconds": unknown_seconds,
|
||||
"is_zero": written == 0 and read == 0 and not is_gap,
|
||||
"is_gap": is_gap,
|
||||
"is_partial": hour_start <= current < hour_end,
|
||||
"is_future": is_future,
|
||||
})
|
||||
hour_start = hour_end
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _unit_scale(max_bytes: int) -> tuple[float, str]:
|
||||
if max_bytes >= 1_000_000_000_000:
|
||||
return 1e12, "TB"
|
||||
@@ -1513,17 +1904,12 @@ class FenrisTuiApp(App):
|
||||
def _set_activity_view(self, view: str) -> None:
|
||||
self._activity_view = view
|
||||
graph = self.query_one("#usage-history")
|
||||
if view == "live":
|
||||
self._browse_date = None
|
||||
elif view == "day" and graph._day_data and graph.view_mode != "hourly":
|
||||
if self._browse_date is not None:
|
||||
graph.drill_day = self._browse_date
|
||||
graph.view_mode = "hourly"
|
||||
self._on_graph_drill(self._browse_date)
|
||||
else:
|
||||
graph._enter_drill()
|
||||
if view == "day":
|
||||
graph.show_selected_day()
|
||||
elif view == "history" and graph.view_mode == "hourly":
|
||||
graph._exit_drill()
|
||||
elif view == "live" and graph.view_mode == "hourly":
|
||||
graph._exit_drill()
|
||||
self._apply_activity_view()
|
||||
self._refresh()
|
||||
self.query_one("#live-activity" if view == "live" else "#usage-history").focus(
|
||||
@@ -1683,35 +2069,24 @@ class FenrisTuiApp(App):
|
||||
|
||||
# --- Activity panel: live and historical volume plots ---
|
||||
graph = self.query_one("#usage-history")
|
||||
day_data = _query_daily_graph_data(conn, self._clock_now)
|
||||
|
||||
# Preserve drill-down state across refresh if still valid
|
||||
day_data = _query_local_day_graph_data(
|
||||
conn, self._clock_now, selected_date=self._browse_date,
|
||||
)
|
||||
graph.set_data(
|
||||
day_data, on_drill=self._on_graph_drill,
|
||||
selected_date=self._browse_date,
|
||||
)
|
||||
if graph.view_mode == "hourly" and graph.drill_day:
|
||||
saved_drill_day = graph.drill_day
|
||||
saved_hour = None
|
||||
if 0 <= graph._hourly_selected < len(graph._hour_data):
|
||||
saved_hour = graph._hour_data[graph._hourly_selected].get("hour")
|
||||
hour_data = _query_hourly_graph_data(
|
||||
conn, saved_drill_day, self._clock_now,
|
||||
from .local_day import query_local_day_summary
|
||||
identity = self._activity_selection.selected_history_day
|
||||
summary = query_local_day_summary(
|
||||
conn,
|
||||
graph.drill_day,
|
||||
identity.timezone if identity is not None else None,
|
||||
self._clock_now,
|
||||
)
|
||||
graph.set_data(
|
||||
day_data, on_drill=self._on_graph_drill,
|
||||
selected_date=self._browse_date,
|
||||
)
|
||||
graph.view_mode = "hourly"
|
||||
graph.drill_day = saved_drill_day
|
||||
graph.set_hour_data(hour_data)
|
||||
if saved_hour is not None:
|
||||
graph._hourly_selected = next(
|
||||
(i for i, item in enumerate(hour_data)
|
||||
if item.get("hour") == saved_hour),
|
||||
graph._hourly_selected,
|
||||
)
|
||||
graph._refresh_hourly()
|
||||
else:
|
||||
graph.set_data(
|
||||
day_data, on_drill=self._on_graph_drill,
|
||||
selected_date=self._browse_date,
|
||||
graph.set_hour_data(
|
||||
_query_local_hourly_graph_data(conn, summary, self._clock_now)
|
||||
)
|
||||
|
||||
# --- Drive-health pane (§7.2 right, issue #79) ---
|
||||
@@ -1788,8 +2163,12 @@ class FenrisTuiApp(App):
|
||||
)
|
||||
tz_name = detect_system_tz()
|
||||
if self._browse_date is not None:
|
||||
identity = self._activity_selection.selected_history_day
|
||||
local = query_local_day_summary(
|
||||
conn, self._browse_date, clock_now=self._clock_now
|
||||
conn,
|
||||
self._browse_date,
|
||||
identity.timezone if identity is not None else None,
|
||||
self._clock_now,
|
||||
)
|
||||
else:
|
||||
local = query_current_local_day(conn, self._clock_now, tz_name)
|
||||
@@ -1862,9 +2241,17 @@ class FenrisTuiApp(App):
|
||||
with read_status(
|
||||
self.store_path, self._clock_now, query_services=False,
|
||||
) as (conn, _):
|
||||
from .local_day import query_local_day_summary
|
||||
identity = self._activity_selection.selected_history_day
|
||||
summary = query_local_day_summary(
|
||||
conn,
|
||||
day,
|
||||
identity.timezone if identity is not None else None,
|
||||
self._clock_now,
|
||||
) if conn is not None else None
|
||||
graph.set_hour_data(
|
||||
_query_hourly_graph_data(conn, day, self._clock_now)
|
||||
if conn is not None else []
|
||||
_query_local_hourly_graph_data(conn, summary, self._clock_now)
|
||||
if conn is not None and summary is not None else []
|
||||
)
|
||||
if conn is not None:
|
||||
self._render_local_day(conn)
|
||||
@@ -1948,21 +2335,27 @@ class FenrisTuiApp(App):
|
||||
def _browse_day(self, offset: int) -> None:
|
||||
"""Move selection by *offset* days from the current position."""
|
||||
graph = self.query_one("#usage-history")
|
||||
if not graph._day_data:
|
||||
return
|
||||
# Find the currently selected day's date string
|
||||
current_day = graph._day_data[graph.selected_index].get("day")
|
||||
current_day = self._browse_date
|
||||
if current_day is None and 0 <= graph.selected_index < len(graph._day_data):
|
||||
current_day = graph._day_data[graph.selected_index].get("day")
|
||||
if current_day is None:
|
||||
return
|
||||
# Compute target date
|
||||
current_date = datetime.strptime(current_day, "%Y-%m-%d").date()
|
||||
target_date = current_date + timedelta(days=offset)
|
||||
if offset > 0:
|
||||
from .tz_util import detect_system_tz
|
||||
today = self._clock_now.astimezone(
|
||||
ZoneInfo(detect_system_tz())
|
||||
).date()
|
||||
if target_date > today:
|
||||
return
|
||||
target_str = target_date.strftime("%Y-%m-%d")
|
||||
# Navigate to target, adjusting range if needed
|
||||
if graph.navigate_to_date(target_str):
|
||||
self._browse_date = target_str
|
||||
if graph.view_mode == "hourly":
|
||||
graph._exit_drill()
|
||||
# Navigate to target, adjusting range if needed
|
||||
graph.navigate_to_date(target_str)
|
||||
self._browse_date = target_str
|
||||
self._set_activity_view("day")
|
||||
|
||||
def action_prev_day(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user