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
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
320
src/timesheets/status.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
assert row["duration_hours"] > 0
|
if row["duration_hours"] is not None:
|
||||||
# The incomplete row (09:55 | empty end) must have been skipped
|
assert row["duration_hours"] > 0
|
||||||
|
# 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
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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue