feat(status): add status subcommand with day and week metrics

- Add status.py with compute_day_status() and compute_week_status()
- Add projected_hours_for_day(): for each entry, use its duration if
  closed, or the next entry's start time as the close time if open
- Open entries (start present, end absent) are preserved by the parser
  instead of being skipped; aggregate_rows() skips them for summaries
- Expected end is computed by filling remaining hours into available
  time slots from the open entry's start; clamped to latest_end if a
  pre-logged entry ends later, with a note explaining why
- Projected week total sums projected_hours_for_day() across all days
- Add status subcommand to cli.py with shared source/day arguments
- Add [work] daily_hours / weekly_hours config keys (default 8 / 40)
- Add timesheets.example.toml [work] section
- Add tests for projected_hours_for_day, compute_day_status,
  compute_week_status and all DayStatus/WeekStatus fields
This commit is contained in:
Jef Roosens 2026-05-22 13:35:22 +02:00
parent d5dbe8791b
commit f372a691d4
Signed by: Jef Roosens
GPG key ID: 119385BCAA005C21
8 changed files with 799 additions and 7 deletions

View file

@ -2,7 +2,10 @@ import csv
import sys
from collections import OrderedDict
from datetime import date
from typing import IO
from typing import IO, TYPE_CHECKING
if TYPE_CHECKING:
from .status import DayStatus, WeekStatus
from .projects import resolve_project_task
from .utils import decimal_to_hhmm
@ -206,3 +209,87 @@ def _aggregate(rows: list[dict]) -> list[dict]:
{"project": p, "description": d, "quantity": totals[(p, d)]}
for p, d in key_order
]
# ---------------------------------------------------------------------------
# Status output
# ---------------------------------------------------------------------------
def print_status(day: "DayStatus", week: "WeekStatus") -> None:
"""Print the status dashboard to stdout."""
hh = decimal_to_hhmm
W = 42 # label column width
def row(label: str, value: str, note: str = "") -> None:
suffix = f" ({note})" if note else ""
print(f" {label:<{W - 2}} {value}{suffix}")
def sep(char: str = "-", width: int = W + 12) -> None:
print(" " + char * width)
# ---- Today ----
print(f"\n Today {day.target_date.strftime('%A, %Y-%m-%d')}")
sep()
start_str = day.day_start.strftime("%H:%M") if day.day_start else "?"
row("Started", start_str)
logged_note = ""
if day.has_open_entry:
open_str = (
day.open_entry_start.strftime("%H:%M") if day.open_entry_start else "?"
)
logged_note = f"open entry since {open_str}"
if day.future_hours > 0:
logged_note += f", {hh(day.future_hours)} pre-logged ahead"
elif day.future_hours > 0:
logged_note = f"{hh(day.future_hours)} pre-logged ahead"
row("Logged", f"{hh(day.logged_hours)} of {hh(day.daily_target)}", logged_note)
row("Remaining today", hh(day.remaining_hours))
if day.expected_end is not None:
if day.remaining_hours == 0:
note = "done"
elif day.latest_end_exceeds_expected:
note = "pre-logged entry ends later than needed"
else:
note = ""
row("Expected end", day.expected_end.strftime("%H:%M"), note)
else:
row("Expected end", "unknown")
# ---- Week ----
print(
f"\n Week W{day.target_date.isocalendar()[1]:02d} "
f"({week.week_start.strftime('%d/%m')} \u2013 {week.week_end.strftime('%d/%m')})"
)
sep()
row(
"Logged this week",
f"{hh(week.total_logged)} of {hh(week.weekly_target)}",
)
row("Remaining this week", hh(week.remaining_hours))
if week.daily_average is not None:
row(
"Daily average",
hh(week.daily_average),
f"{week.days_worked} day{'s' if week.days_worked != 1 else ''} worked",
)
if week.projected_total is not None:
deficit = week.weekly_target - week.projected_total
if deficit > 0.05:
note = f"{hh(deficit)} short"
elif deficit < -0.05:
note = f"{hh(abs(deficit))} over"
else:
note = "exact"
row("Projected week total", hh(week.projected_total), note)
if week.on_track is not None:
row("On track", "yes \u2713" if week.on_track else "no \u2717")
print()