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

@ -3,8 +3,16 @@ import os
import sys import sys
from datetime import date from datetime import date
from .config import find_default_config, get_map_path, get_token, load_config from .config import (
find_default_config,
get_daily_target,
get_map_path,
get_token,
get_weekly_target,
load_config,
)
from .output import ( from .output import (
print_status,
print_summary, print_summary,
print_summary_short, print_summary_short,
print_summary_weekly, print_summary_weekly,
@ -121,6 +129,12 @@ def build_parser() -> argparse.ArgumentParser:
) )
_add_shared_args(csv_parser) _add_shared_args(csv_parser)
status_parser = subparsers.add_parser(
"status",
help="Show how many hours remain today and this week.",
)
_add_shared_args(status_parser)
return parser return parser
@ -272,6 +286,49 @@ def _cmd_summary(args: argparse.Namespace, config: dict) -> None:
_write(print_summary, aggregated, project_map) _write(print_summary, aggregated, project_map)
def _cmd_status(args: argparse.Namespace, config: dict) -> None:
from datetime import timedelta
from .status import compute_day_status, compute_week_status
target_date, _ = _resolve_date(args)
daily_target = get_daily_target(config)
weekly_target = get_weekly_target(config)
week_start = target_date - timedelta(days=target_date.weekday())
# Fetch the full week document once for both day and week status
if args.joplin:
from .joplin import fetch_week_note
token = _resolve_token(args, config)
try:
content = fetch_week_note(token, target_date)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
lines = content.splitlines()
else:
if args.input == "-":
lines = sys.stdin.read().splitlines()
else:
try:
with open(args.input, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
except FileNotFoundError:
print(f"Error: file not found: {args.input}", file=sys.stderr)
sys.exit(1)
from .parser import filter_rows_by_date, filter_week_sections
day_rows = filter_rows_by_date(lines, target_date)
day_sections = filter_week_sections(lines, week_start)
day_status = compute_day_status(day_rows, target_date, daily_target)
week_status = compute_week_status(day_sections, target_date, weekly_target)
print_status(day_status, week_status)
def _cmd_csv(args: argparse.Namespace, config: dict) -> None: def _cmd_csv(args: argparse.Namespace, config: dict) -> None:
target_date, date_str = _resolve_date(args) target_date, date_str = _resolve_date(args)
rows = _resolve_rows(args, config, target_date) rows = _resolve_rows(args, config, target_date)
@ -303,3 +360,5 @@ def main() -> None:
_cmd_summary(args, config) _cmd_summary(args, config)
elif args.command == "csv": elif args.command == "csv":
_cmd_csv(args, config) _cmd_csv(args, config)
elif args.command == "status":
_cmd_status(args, config)

View file

@ -52,3 +52,13 @@ def get_token(config: dict) -> str | None:
def get_map_path(config: dict) -> str | None: def get_map_path(config: dict) -> str | None:
"""Extract projects.map from a loaded config dict, or None.""" """Extract projects.map from a loaded config dict, or None."""
return config.get("projects", {}).get("map") return config.get("projects", {}).get("map")
def get_daily_target(config: dict) -> float:
"""Extract work.daily_hours from config, defaulting to 8.0."""
return float(config.get("work", {}).get("daily_hours", 8.0))
def get_weekly_target(config: dict) -> float:
"""Extract work.weekly_hours from config, defaulting to 40.0."""
return float(config.get("work", {}).get("weekly_hours", 40.0))

View file

@ -2,7 +2,10 @@ import csv
import sys import sys
from collections import OrderedDict from collections import OrderedDict
from datetime import date 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 .projects import resolve_project_task
from .utils import decimal_to_hhmm 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)]} {"project": p, "description": d, "quantity": totals[(p, d)]}
for p, d in key_order 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()

View file

@ -114,7 +114,21 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]:
continue continue
if not re.match(r"^\d+:\d{2}$", start): if not re.match(r"^\d+:\d{2}$", start):
continue continue
# Open entry: has a start time but no end time yet
if not re.match(r"^\d+:\d{2}$", end): if not re.match(r"^\d+:\d{2}$", end):
if duration is None:
# No end and no duration — preserve as an open entry
rows.append(
{
"start": start,
"end": None,
"duration_hours": None,
"project": project,
"story": story,
"note": note,
}
)
continue continue
if duration is not None: if duration is not None:
@ -249,6 +263,8 @@ def aggregate_rows(rows: list[dict]) -> list[dict]:
totals: dict[tuple, float] = defaultdict(float) totals: dict[tuple, float] = defaultdict(float)
for row in rows: for row in rows:
if row["duration_hours"] is None:
continue # skip open entries
description = build_description(row["story"], row["note"]) description = build_description(row["story"], row["note"])
key = (row["project"].strip(), description) key = (row["project"].strip(), description)
if key not in totals: if key not in totals:

320
src/timesheets/status.py Normal file
View file

@ -0,0 +1,320 @@
"""
Status calculations for the timesheets status command.
All time arithmetic is done in decimal hours. The status is computed
purely from the timesheet data no wall-clock dependency.
Open entries (start time present, end time absent) act as the anchor
for expected-end calculations:
- If an open entry is the LAST entry of the day:
remaining = daily_target - logged_so_far
expected_end = open_start + (logged_before_open + remaining)
- If entries FOLLOW the open entry:
the gap between open_start and the next entry's start is treated
as fillable time; expected_end is computed from the last known
end time + any remaining hours after all closed entries.
- No open entry:
expected_end = last_end + remaining
"""
import re
from dataclasses import dataclass
from datetime import date, time, timedelta
def _parse_hhmm(s: str) -> time | None:
"""Parse a HH:MM string into a time object, returning None on failure."""
if s is None:
return None
m = re.match(r"^(\d{1,2}):(\d{2})$", s.strip())
if not m:
return None
try:
return time(int(m.group(1)), int(m.group(2)))
except ValueError:
return None
def _time_to_hours(t: time) -> float:
return t.hour + t.minute / 60.0
def _hours_to_time(hours: float) -> time:
"""Convert decimal hours to a time object (clamps to 23:59)."""
total_minutes = round(hours * 60)
h, m = divmod(total_minutes, 60)
h = min(h, 23)
m = min(m, 59)
return time(h, m)
@dataclass
class DayStatus:
target_date: date
# Hours
logged_hours: float # sum of all closed entries
future_hours: float # sum of closed entries that start after the open entry
daily_target: float
# Open entry
has_open_entry: bool # True if there is an entry with no end time
open_entry_start: time | None
# Times
day_start: time | None # start time of first entry
latest_end: (
time | None
) # end time of last closed entry (including future closed ones)
# Derived
remaining_hours: float # daily_target - logged_hours (floored to 0)
expected_end: time | None
latest_end_exceeds_expected: (
bool # True when a pre-logged entry ends later than expected_end
)
hours_to_log_today: float # logged_hours + (latest_end - open_start) if open entry, else logged_hours
@dataclass
class WeekStatus:
week_start: date
week_end: date
weekly_target: float
logged_hours_by_day: dict[date, float]
total_logged: float
remaining_hours: float
days_worked: int
daily_average: float | None
projected_total: float | None
on_track: bool | None
def compute_day_status(
rows: list[dict],
target_date: date,
daily_target: float = 8.0,
) -> DayStatus:
"""
Compute per-day status metrics from raw (unaggregated) rows.
Rows may include open entries (duration_hours is None, end is None).
The wall clock is not used; open entries serve as the work-in-progress
anchor instead.
"""
if not rows:
return DayStatus(
target_date=target_date,
logged_hours=0.0,
future_hours=0.0,
daily_target=daily_target,
has_open_entry=False,
open_entry_start=None,
day_start=None,
latest_end=None,
remaining_hours=daily_target,
expected_end=None,
latest_end_exceeds_expected=False,
hours_to_log_today=0.0,
)
# Sort rows by start time so we can reason about ordering
def _sort_key(r: dict) -> float:
t = _parse_hhmm(r["start"])
return _time_to_hours(t) if t else 0.0
sorted_rows = sorted(rows, key=_sort_key)
# Find the open entry (if any) — the one with no end time
open_idx: int | None = None
for i, row in enumerate(sorted_rows):
if row["duration_hours"] is None:
open_idx = i
break # only one open entry expected
# Closed entries only
closed_rows = [r for r in sorted_rows if r["duration_hours"] is not None]
logged_hours = sum(r["duration_hours"] for r in closed_rows)
day_start: time | None = None
latest_end: time | None = None
latest_end_hours = 0.0
for row in closed_rows:
start_t = _parse_hhmm(row["start"])
end_t = _parse_hhmm(row["end"])
if start_t is not None:
if day_start is None or start_t < day_start:
day_start = start_t
if end_t is not None:
end_h = _time_to_hours(end_t)
if end_h > latest_end_hours:
latest_end_hours = end_h
latest_end = end_t
# Entries after the open entry are "future closed" (pre-logged)
future_hours = 0.0
if open_idx is not None:
for row in sorted_rows[open_idx + 1 :]:
if row["duration_hours"] is not None:
future_hours += row["duration_hours"]
remaining_hours = max(0.0, daily_target - logged_hours)
has_open_entry = open_idx is not None
open_entry_start = (
_parse_hhmm(sorted_rows[open_idx]["start"]) if has_open_entry else None
)
# --- Expected end calculation ---
expected_end: time | None = None
if has_open_entry:
open_start_hours = _time_to_hours(open_entry_start) if open_entry_start else 0.0
entries_after_open = [
r for r in sorted_rows[open_idx + 1 :] if r["duration_hours"] is not None
]
if not entries_after_open:
# Open entry is last: work continuously from open_start
expected_end = _hours_to_time(open_start_hours + remaining_hours)
elif remaining_hours == 0:
# Already done — expected end is the last known entry's end
expected_end = latest_end
else:
# Fill remaining_hours into available slots starting from open_start.
# Slots: [open_start → next_entry_start] then each subsequent entry.
# Any remaining hours after all known slots extend beyond latest_end.
budget = remaining_hours
cursor = open_start_hours
for i, entry in enumerate(entries_after_open):
entry_start_h = _time_to_hours(_parse_hhmm(entry["start"]) or time(0))
entry_end_h = _time_to_hours(_parse_hhmm(entry["end"]) or time(0))
# Gap before this entry is available filling time
gap = max(0.0, entry_start_h - cursor)
if budget <= gap:
expected_end = _hours_to_time(cursor + budget)
budget = 0.0
break
budget -= gap
# The entry itself is already logged — advance cursor past it
cursor = entry_end_h
if budget > 0:
# Still hours left after all known entries — extend beyond latest end
expected_end = _hours_to_time(cursor + budget)
elif expected_end is None:
expected_end = latest_end
else:
# No open entry: purely data-driven
if latest_end is not None:
if remaining_hours > 0:
expected_end = _hours_to_time(latest_end_hours + remaining_hours)
else:
expected_end = latest_end
# If any pre-logged entry ends later than the computed expected_end, use
# that as the effective end and flag it so the caller can explain why.
latest_end_exceeds_expected = False
if (
expected_end is not None
and latest_end is not None
and latest_end > expected_end
):
latest_end_exceeds_expected = True
expected_end = latest_end
elif expected_end is None and latest_end is not None:
expected_end = latest_end
# hours_to_log_today is unused in DayStatus now; kept at 0 for compatibility
hours_to_log_today = 0.0
return DayStatus(
target_date=target_date,
logged_hours=logged_hours,
future_hours=future_hours,
daily_target=daily_target,
has_open_entry=has_open_entry,
open_entry_start=open_entry_start,
day_start=day_start,
latest_end=latest_end,
remaining_hours=remaining_hours,
expected_end=expected_end,
latest_end_exceeds_expected=latest_end_exceeds_expected,
hours_to_log_today=hours_to_log_today,
)
def projected_hours_for_day(rows: list[dict]) -> float:
"""
Calculate the total hours that will be logged for a day.
For each entry:
- Closed (duration_hours is not None): add its duration.
- Open (duration_hours is None): use the next entry's start time as the
close time. If there is no next entry the open entry contributes 0.
"""
sorted_rows = sorted(
rows,
key=lambda r: _time_to_hours(_parse_hhmm(r["start"]) or time(0)),
)
total = 0.0
for i, row in enumerate(sorted_rows):
if row["duration_hours"] is not None:
total += row["duration_hours"]
else:
# Open entry: close at the next entry's start, if one exists
if i + 1 < len(sorted_rows):
open_start = _parse_hhmm(row["start"])
next_start = _parse_hhmm(sorted_rows[i + 1]["start"])
if open_start is not None and next_start is not None:
total += max(
0.0, _time_to_hours(next_start) - _time_to_hours(open_start)
)
return total
def compute_week_status(
day_sections: list[tuple[date, list[dict]]],
target_date: date,
weekly_target: float = 40.0,
) -> WeekStatus:
"""
Compute per-week status metrics from all days in the week.
day_sections is a list of (date, raw_rows) pairs for the week.
Open entries are closed at the next entry's start time for projection purposes.
"""
week_start = target_date - timedelta(days=target_date.weekday())
week_end = week_start + timedelta(days=4)
logged_by_day: dict[date, float] = {}
for day, rows in day_sections:
total = sum(
r["duration_hours"] for r in rows if r["duration_hours"] is not None
)
logged_by_day[day] = total
total_logged = sum(logged_by_day.values())
remaining = max(0.0, weekly_target - total_logged)
days_worked = len([h for h in logged_by_day.values() if h > 0])
daily_average = total_logged / days_worked if days_worked > 0 else None
# Projected total: sum projected hours for each day in the week
projected_total = sum(projected_hours_for_day(rows) for _, rows in day_sections)
on_track = projected_total >= weekly_target
return WeekStatus(
week_start=week_start,
week_end=week_end,
weekly_target=weekly_target,
logged_hours_by_day=logged_by_day,
total_logged=total_logged,
remaining_hours=remaining,
days_worked=days_worked,
daily_average=daily_average,
projected_total=projected_total,
on_track=on_track,
)

View file

@ -177,12 +177,16 @@ class TestParseTable:
assert parse_table(lines) == [] assert parse_table(lines) == []
def test_empty_end_time_row_skipped(self): def test_empty_end_time_row_skipped(self):
# Open entry (no end, no duration) is now preserved with duration_hours=None
lines = [ lines = [
"| Start | End | Project | Story | Note |", "| Start | End | Project | Story | Note |",
"|-------|-------|---------|-------|------|", "|-------|-------|---------|-------|------||",
"| 09:55 | | bugs | | |", "| 09:55 | | bugs | | |",
] ]
assert parse_table(lines, has_duration_col=False) == [] rows = parse_table(lines, has_duration_col=False)
assert len(rows) == 1
assert rows[0]["duration_hours"] is None
assert rows[0]["end"] is None
def test_empty_input(self): def test_empty_input(self):
assert parse_table([]) == [] assert parse_table([]) == []
@ -231,15 +235,18 @@ class TestParseDocument:
# File has 5 daily tables; expect a healthy number of rows # File has 5 daily tables; expect a healthy number of rows
assert len(rows) > 20 assert len(rows) > 20
# All rows must have expected keys # All rows must have expected keys
# All closed rows must have positive duration; open entries have None
for row in rows: for row in rows:
assert "project" in row assert "project" in row
assert "duration_hours" in row assert "duration_hours" in row
if row["duration_hours"] is not None:
assert row["duration_hours"] > 0 assert row["duration_hours"] > 0
# The incomplete row (09:55 | empty end) must have been skipped # The open entry (09:55, no end) must be preserved with duration_hours=None
incomplete = [ incomplete = [
r for r in rows if r["start"] == "09:55" and r["project"] == "bugs" r for r in rows if r["start"] == "09:55" and r["project"] == "bugs"
] ]
assert all(r["duration_hours"] > 0 for r in incomplete) assert len(incomplete) == 1
assert incomplete[0]["duration_hours"] is None
def test_week_file_no_markdown_links_in_stories(self): def test_week_file_no_markdown_links_in_stories(self):
"""Markdown link syntax must be stripped from story/note fields.""" """Markdown link syntax must be stripped from story/note fields."""

289
tests/test_status.py Normal file
View 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

View file

@ -3,3 +3,7 @@ token = "your_api_token_here"
[projects] [projects]
map = "/path/to/project_map.json" map = "/path/to/project_map.json"
[work]
daily_hours = 8.0
weekly_hours = 40.0