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
289
tests/test_status.py
Normal file
289
tests/test_status.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
from datetime import date, time
|
||||
|
||||
import pytest
|
||||
|
||||
from timesheets.status import (
|
||||
DayStatus,
|
||||
WeekStatus,
|
||||
_hours_to_time,
|
||||
_parse_hhmm,
|
||||
_time_to_hours,
|
||||
compute_day_status,
|
||||
compute_week_status,
|
||||
projected_hours_for_day,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseHhmm:
|
||||
def test_valid(self):
|
||||
assert _parse_hhmm("08:30") == time(8, 30)
|
||||
|
||||
def test_single_digit_hour(self):
|
||||
assert _parse_hhmm("9:05") == time(9, 5)
|
||||
|
||||
def test_invalid(self):
|
||||
assert _parse_hhmm("bad") is None
|
||||
assert _parse_hhmm("") is None
|
||||
|
||||
def test_none_input(self):
|
||||
assert _parse_hhmm(None) is None
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert _parse_hhmm(" 08:30 ") == time(8, 30)
|
||||
|
||||
|
||||
class TestHoursToTime:
|
||||
def test_basic(self):
|
||||
assert _hours_to_time(8.5) == time(8, 30)
|
||||
|
||||
def test_rounds_minutes(self):
|
||||
assert _hours_to_time(8.0 + 10 / 60) == time(8, 10)
|
||||
|
||||
def test_clamps_hours_over_23(self):
|
||||
# 25h = 25:00 → clamped to 23:00
|
||||
assert _hours_to_time(25.0) == time(23, 0)
|
||||
|
||||
def test_clamps_with_minutes(self):
|
||||
# 24h 30min → hours clamped to 23, minutes stay as 30 → 23:30
|
||||
assert _hours_to_time(24.0 + 30 / 60) == time(23, 30)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_day_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _row(
|
||||
start: str, end: str | None, hours: float | None, project: str = "bugs"
|
||||
) -> dict:
|
||||
return {
|
||||
"start": start,
|
||||
"end": end,
|
||||
"duration_hours": hours,
|
||||
"project": project,
|
||||
"story": "",
|
||||
"note": "",
|
||||
}
|
||||
|
||||
|
||||
def _closed(start: str, end: str, hours: float) -> dict:
|
||||
return _row(start, end, hours)
|
||||
|
||||
|
||||
def _open(start: str) -> dict:
|
||||
return _row(start, None, None)
|
||||
|
||||
|
||||
TODAY = date(2026, 5, 22)
|
||||
|
||||
|
||||
class TestComputeDayStatus:
|
||||
def test_empty_rows(self):
|
||||
s = compute_day_status([], TODAY)
|
||||
assert s.logged_hours == 0.0
|
||||
assert s.remaining_hours == 8.0
|
||||
assert s.has_open_entry is False
|
||||
assert s.day_start is None
|
||||
assert s.expected_end is None
|
||||
|
||||
def test_all_closed_no_remaining(self):
|
||||
rows = [_closed("08:00", "16:00", 8.0)]
|
||||
s = compute_day_status(rows, TODAY)
|
||||
assert s.logged_hours == 8.0
|
||||
assert s.remaining_hours == 0.0
|
||||
assert s.expected_end == time(16, 0)
|
||||
|
||||
def test_all_closed_with_remaining(self):
|
||||
rows = [_closed("08:00", "12:00", 4.0)]
|
||||
s = compute_day_status(rows, TODAY)
|
||||
assert s.remaining_hours == 4.0
|
||||
# expected end = 12:00 + 4:00 = 16:00
|
||||
assert s.expected_end == time(16, 0)
|
||||
|
||||
def test_remaining_floored_at_zero(self):
|
||||
rows = [_closed("08:00", "17:00", 9.0)]
|
||||
s = compute_day_status(rows, TODAY)
|
||||
assert s.remaining_hours == 0.0
|
||||
|
||||
def test_custom_daily_target(self):
|
||||
rows = [_closed("08:00", "12:00", 4.0)]
|
||||
s = compute_day_status(rows, TODAY, daily_target=6.0)
|
||||
assert s.remaining_hours == 2.0
|
||||
|
||||
# --- Open entry as last entry ---
|
||||
|
||||
def test_open_entry_last_expected_end(self):
|
||||
# 2h logged before open, open starts at 10:00
|
||||
# remaining = 8 - 2 = 6h → end = 10:00 + 6:00 = 16:00
|
||||
rows = [_closed("08:00", "10:00", 2.0), _open("10:00")]
|
||||
s = compute_day_status(rows, TODAY)
|
||||
assert s.has_open_entry is True
|
||||
assert s.open_entry_start == time(10, 0)
|
||||
assert s.remaining_hours == 6.0
|
||||
assert s.expected_end == time(16, 0)
|
||||
|
||||
def test_open_entry_only(self):
|
||||
# Nothing logged yet, open entry at 08:15
|
||||
# remaining = 8h → end = 08:15 + 8:00 = 16:15
|
||||
rows = [_open("08:15")]
|
||||
s = compute_day_status(rows, TODAY)
|
||||
assert s.logged_hours == 0.0
|
||||
assert s.remaining_hours == 8.0
|
||||
assert s.expected_end == time(16, 15)
|
||||
|
||||
# --- Open entry with future closed entries after it ---
|
||||
|
||||
def test_open_entry_with_future_entries(self):
|
||||
# 2h logged, open at 10:00, meeting 16:00-17:30 (1.5h) pre-logged
|
||||
# remaining = 8 - 2 - 1.5 = 4.5h
|
||||
# gap 10:00->16:00 = 6h, budget 4.5 fits -> expected_end = 14:30
|
||||
# latest_end = 17:30 > 14:30 -> clamped to 17:30, flag set
|
||||
rows = [
|
||||
_closed("08:00", "10:00", 2.0),
|
||||
_open("10:00"),
|
||||
_closed("16:00", "17:30", 1.5),
|
||||
]
|
||||
s = compute_day_status(rows, TODAY)
|
||||
assert s.has_open_entry is True
|
||||
assert s.future_hours == 1.5
|
||||
assert s.logged_hours == 3.5
|
||||
assert s.remaining_hours == 4.5
|
||||
assert s.latest_end == time(17, 30)
|
||||
assert s.expected_end == time(17, 30)
|
||||
assert s.latest_end_exceeds_expected is True
|
||||
|
||||
def test_open_entry_gap_not_enough_extends_past_last_entry(self):
|
||||
# 4h logged, open at 13:00, meeting 14:00-15:00 (1h) pre-logged
|
||||
# remaining = 8 - 4 - 1 = 3h
|
||||
# Slots: gap 13:00->14:00 = 1h (budget 3->2), entry ends 15:00, then 2h left → 15:00+2 = 17:00
|
||||
rows = [
|
||||
_closed("08:00", "13:00", 4.0),
|
||||
_open("13:00"),
|
||||
_closed("14:00", "15:00", 1.0),
|
||||
]
|
||||
s = compute_day_status(rows, TODAY)
|
||||
assert s.remaining_hours == 3.0
|
||||
assert s.expected_end == time(17, 0)
|
||||
|
||||
def test_open_entry_future_entries_cover_remaining(self):
|
||||
# 4h logged, open at 12:00, meeting 13:00-17:00 (4h) pre-logged
|
||||
# remaining = 8 - 4 - 4 = 0 → expected end = 17:00
|
||||
rows = [
|
||||
_closed("08:00", "12:00", 4.0),
|
||||
_open("12:00"),
|
||||
_closed("13:00", "17:00", 4.0),
|
||||
]
|
||||
s = compute_day_status(rows, TODAY)
|
||||
assert s.remaining_hours == 0.0
|
||||
assert s.expected_end == time(17, 0)
|
||||
|
||||
def test_day_start_from_earliest(self):
|
||||
rows = [_closed("09:00", "10:00", 1.0), _closed("08:00", "09:00", 1.0)]
|
||||
s = compute_day_status(rows, TODAY)
|
||||
assert s.day_start == time(8, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_week_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MON = date(2026, 5, 18)
|
||||
TUE = date(2026, 5, 19)
|
||||
WED = date(2026, 5, 20)
|
||||
THU = date(2026, 5, 21)
|
||||
FRI = date(2026, 5, 22)
|
||||
|
||||
|
||||
def _day_section(d: date, hours: float) -> tuple[date, list[dict]]:
|
||||
return (d, [_closed("08:00", "16:00", hours)])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# projected_hours_for_day
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProjectedHoursForDay:
|
||||
def test_all_closed(self):
|
||||
rows = [_closed("08:00", "12:00", 4.0), _closed("13:00", "17:00", 4.0)]
|
||||
assert projected_hours_for_day(rows) == 8.0
|
||||
|
||||
def test_open_entry_with_next(self):
|
||||
rows = [
|
||||
_closed("08:00", "13:00", 5.0),
|
||||
_open("13:00"),
|
||||
_closed("16:00", "17:30", 1.5),
|
||||
]
|
||||
assert projected_hours_for_day(rows) == 5.0 + 3.0 + 1.5
|
||||
|
||||
def test_open_entry_last_contributes_zero(self):
|
||||
rows = [_closed("08:00", "13:00", 5.0), _open("13:00")]
|
||||
assert projected_hours_for_day(rows) == 5.0
|
||||
|
||||
def test_empty(self):
|
||||
assert projected_hours_for_day([]) == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_week_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestComputeWeekStatus:
|
||||
def test_all_closed(self):
|
||||
sections = [_day_section(MON, 8.0), _day_section(TUE, 8.0)]
|
||||
s = compute_week_status(sections, FRI)
|
||||
assert s.total_logged == 16.0
|
||||
assert s.projected_total == 16.0
|
||||
assert s.on_track is False
|
||||
|
||||
def test_full_week_on_track(self):
|
||||
sections = [_day_section(d, 8.0) for d in [MON, TUE, WED, THU, FRI]]
|
||||
s = compute_week_status(sections, FRI)
|
||||
assert s.projected_total == 40.0
|
||||
assert s.on_track is True
|
||||
|
||||
def test_open_entry_projected_with_next(self):
|
||||
# Fri: 5h closed before open, open at 13:00, next at 16:00 (3h gap), then 1.5h
|
||||
# projected for Fri = 5 + 3 + 1.5 = 9.5
|
||||
fri_rows = [
|
||||
_closed("08:00", "13:00", 5.0),
|
||||
_open("13:00"),
|
||||
_closed("16:00", "17:30", 1.5),
|
||||
]
|
||||
sections = [_day_section(d, 8.0) for d in [MON, TUE, WED, THU]] + [
|
||||
(FRI, fri_rows)
|
||||
]
|
||||
s = compute_week_status(sections, FRI)
|
||||
assert s.projected_total == 32.0 + 9.5
|
||||
|
||||
def test_remaining_floored_at_zero(self):
|
||||
sections = [_day_section(d, 9.0) for d in [MON, TUE, WED, THU, FRI]]
|
||||
s = compute_week_status(sections, FRI)
|
||||
assert s.total_logged == 45.0
|
||||
assert s.remaining_hours == 0.0
|
||||
|
||||
def test_empty_week(self):
|
||||
s = compute_week_status([], FRI)
|
||||
assert s.total_logged == 0.0
|
||||
assert s.days_worked == 0
|
||||
assert s.daily_average is None
|
||||
assert s.projected_total == 0.0
|
||||
|
||||
def test_open_entry_excluded_from_logged_total(self):
|
||||
sections = [(MON, [_closed("08:00", "10:00", 2.0), _open("10:00")])]
|
||||
s = compute_week_status(sections, MON)
|
||||
# logged only counts closed entries
|
||||
assert s.total_logged == 2.0
|
||||
# projected: open entry last -> contributes 0, so projected = 2.0
|
||||
assert s.projected_total == 2.0
|
||||
|
||||
def test_week_bounds(self):
|
||||
sections = [_day_section(WED, 8.0)]
|
||||
s = compute_week_status(sections, WED)
|
||||
assert s.week_start == MON
|
||||
assert s.week_end == FRI
|
||||
Loading…
Add table
Add a link
Reference in a new issue