Browse activity dates from the keyboard (#92)

Add keyboard-driven date navigation to the TUI:
- [ and ] keys select previous/next day, adjusting the visible range
- g opens a date entry modal for direct date navigation
- t returns to today's live view from any historical browsing
- Background refresh preserves the browsed selection
- Local-day widget shows data for the selected date
- Updated help screen and action legend with new bindings
- DatePickerScreen modal with input validation
- 24 headless tests covering navigation, date entry, browse
  stability, local-day evidence, and constrained widths

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
xavierk
2026-09-18 14:17:02 +05:30
co-authored by CommandCodeBot
parent e12f4a574c
commit 5df8a12339
2 changed files with 766 additions and 10 deletions
+174 -10
View File
@@ -26,7 +26,7 @@ from textual.binding import Binding
from textual.containers import Container, Horizontal, VerticalScroll
from textual.screen import ModalScreen
from textual.widget import Widget
from textual.widgets import Static
from textual.widgets import Input, Static
from .projection import (
ConfidenceState,
@@ -63,7 +63,7 @@ from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
_RESUME_HINT = "r Resume — enable monitoring and future boots"
_ACTION_LEGEND = (
"p Pause · " + _RESUME_HINT + "\n"
"c Collect now · t Today · m Motion · d Disclosures · ? Help"
"c Collect now · t Today · [ ] Day · g Date · m Motion · d Disclosures · ? Help"
)
def _format_remaining(seconds: float) -> str:
@@ -191,10 +191,11 @@ class DailyBarGraph(Widget):
self,
day_data: List[Dict[str, Any]],
on_drill: Any = None,
selected_date: Optional[str] = None,
) -> None:
"""Update graph with day data. on_drill(day) called on drill entry."""
selected_day = None
if 0 <= self.selected_index < len(self._day_data):
selected_day = selected_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
@@ -699,6 +700,42 @@ class DailyBarGraph(Widget):
self._refresh_hourly()
event.stop()
def navigate_to_date(self, date_str: str) -> bool:
"""Navigate to a specific date, adjusting range if needed.
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._day_data[self.selected_index].get("day")
# Count actual data days (not gaps) after the target
n_data_after = sum(
1 for d in self._all_day_data[idx:]
if d.get("sample_count", 0) > 0 or d.get("total_bytes", 0) > 0
)
if n_data_after > self.range_days:
self.range_days = max(n_data_after, min(_RANGE_OPTIONS))
self._trim_to_range()
if not self._day_data:
return False
self.selected_index = next(
(i for i, d in enumerate(self._day_data)
if d.get("day") == date_str),
idx,
)
new_day = self._day_data[self.selected_index].get("day")
return new_day != old_day
def on_click(self, event: Any) -> None:
render = self.query_one("#bar-render")
offset_x = event.x - render.region.x
@@ -1333,6 +1370,7 @@ class HelpScreen(ModalScreen[None]):
+ _ACTION_LEGEND + "\n"
"q Quit the dashboard — monitoring continues\n"
"Tab Focus the graph · ← → Select · 1-4 Change range\n"
"[ Previous day · ] Next day · g Go to date\n"
"Enter Show hours · Esc Return to days\n"
"w Toggle W/R on live graph\n"
"Theme changed via preferences file\n\n"
@@ -1344,6 +1382,68 @@ class HelpScreen(ModalScreen[None]):
self.dismiss()
class DatePickerScreen(ModalScreen[str | None]):
"""Modal screen for entering a date to browse."""
BINDINGS = [
Binding("escape", "cancel", "Cancel", show=False),
]
CSS = """
DatePickerScreen {
align: center middle;
}
#date-picker-content {
width: 40;
height: auto;
border: heavy $accent;
padding: 1 2;
}
#date-input {
width: 100%;
}
#date-error {
width: 100%;
height: auto;
color: $error;
}
"""
def compose(self) -> ComposeResult:
yield Container(
Static("[bold]Go to date[/bold]\nEnter a local date (YYYY-MM-DD):", id="date-picker-header"),
Input(placeholder="YYYY-MM-DD", id="date-input"),
Static("", id="date-error"),
Static("[dim]Enter Select · Esc Cancel[/dim]", id="date-picker-footer"),
id="date-picker-content",
)
def on_mount(self) -> None:
self.query_one("#date-input").focus()
def on_input_submitted(self, event: Any) -> None:
value = event.value.strip()
if not value:
self.query_one("#date-error").update("[red]Please enter a date[/red]")
return
try:
parsed = datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
self.query_one("#date-error").update(
"[red]Invalid date format. Use YYYY-MM-DD.[/red]"
)
return
if parsed > datetime.now().date():
self.query_one("#date-error").update(
"[red]Cannot browse future dates[/red]"
)
return
self.dismiss(value)
def action_cancel(self) -> None:
self.dismiss(None)
# ---------------------------------------------------------------------------
# Main TUI App
# ---------------------------------------------------------------------------
@@ -1451,6 +1551,9 @@ class FenrisTuiApp(App):
Binding("c", "collect", "Collect now", show=False),
Binding("d", "disclose", "Disclosures", show=False),
Binding("t", "today", "Today", show=False),
Binding("left_square_bracket", "prev_day", "Previous day", show=False),
Binding("right_square_bracket", "next_day", "Next day", show=False),
Binding("g", "go_to_date", "Go to date", show=False),
Binding("m", "toggle_motion", "Motion", show=False),
Binding("w", "toggle_measure", "W/R", show=False),
Binding("?", "help", "Help", show=False),
@@ -1485,6 +1588,9 @@ class FenrisTuiApp(App):
# Track constrained state for resize handling (issue #81)
self._is_constrained_mode = False
# Track the date the user is browsing (None = live/today view)
self._browse_date: Optional[str] = None
def compose(self) -> ComposeResult:
with VerticalScroll(id="dashboard-scroll"):
with Container(id="main-grid"):
@@ -1675,7 +1781,10 @@ class FenrisTuiApp(App):
hour_data = _query_hourly_graph_data(
conn, saved_drill_day, self._clock_now,
)
graph.set_data(day_data, on_drill=self._on_graph_drill)
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)
@@ -1687,7 +1796,10 @@ class FenrisTuiApp(App):
)
graph._refresh_hourly()
else:
graph.set_data(day_data, on_drill=self._on_graph_drill)
graph.set_data(
day_data, on_drill=self._on_graph_drill,
selected_date=self._browse_date,
)
# Update constrained summary if in constrained mode (issue #81)
if self._is_constrained_mode:
@@ -1758,12 +1870,18 @@ class FenrisTuiApp(App):
main_grid.refresh(layout=True)
def _render_local_day(self, conn: sqlite3.Connection) -> None:
"""Render the local-day activity totals widget (issue #90)."""
"""Render the local-day activity totals widget (issue #90, #92)."""
try:
from .tz_util import detect_system_tz
from .local_day import query_current_local_day
from .local_day import (
query_current_local_day,
query_local_day_summary,
)
tz_name = detect_system_tz()
local = query_current_local_day(conn, self._clock_now, tz_name)
if self._browse_date is not None:
local = query_local_day_summary(conn, self._browse_date)
else:
local = query_current_local_day(conn, self._clock_now, tz_name)
except Exception:
local = None
@@ -1870,12 +1988,58 @@ class FenrisTuiApp(App):
def action_today(self) -> None:
"""Return to today's live view from any historical browsing."""
# Reset the daily graph to today's data
self._browse_date = None
graph = self.query_one("#usage-history")
if graph.view_mode == "hourly":
graph._exit_drill()
# Reset selection to today (last day in the data)
if graph._day_data:
graph.selected_index = len(graph._day_data) - 1
self._refresh()
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")
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)
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()
self._refresh()
def action_prev_day(self) -> None:
"""Select the previous (older) day."""
self._browse_day(-1)
def action_next_day(self) -> None:
"""Select the next (newer) day."""
self._browse_day(1)
def _go_to_date_callback(self, date_str: str | None) -> None:
"""Handle date picker result."""
if date_str is None:
return
graph = self.query_one("#usage-history")
if graph.view_mode == "hourly":
graph._exit_drill()
self._browse_date = date_str
graph.navigate_to_date(date_str)
self._refresh()
def action_go_to_date(self) -> None:
"""Open the date entry screen."""
self.push_screen(DatePickerScreen(), callback=self._go_to_date_callback)
def action_toggle_motion(self) -> None:
"""Toggle reduced motion preference (issue #80)."""
self._reduced_motion = not self._reduced_motion