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:
parent
d5dbe8791b
commit
f372a691d4
8 changed files with 799 additions and 7 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue