diff --git a/.coverage b/.coverage index 4796620..7f46424 100644 Binary files a/.coverage and b/.coverage differ diff --git a/src/timesheets/cli.py b/src/timesheets/cli.py index 0bf28b3..025162d 100644 --- a/src/timesheets/cli.py +++ b/src/timesheets/cli.py @@ -13,6 +13,7 @@ from .config import ( ) from .output import ( print_status, + print_stories, print_summary, print_summary_short, print_summary_weekly, @@ -135,6 +136,19 @@ def build_parser() -> argparse.ArgumentParser: ) _add_shared_args(status_parser) + stories_parser = subparsers.add_parser( + "stories", + help="List stories worked on, grouped by project.", + ) + _add_shared_args(stories_parser) + stories_parser.add_argument( + "--weekly", + "-w", + action="store_true", + default=False, + help="Show stories for the entire week containing the given day.", + ) + return parser @@ -319,16 +333,56 @@ def _cmd_status(args: argparse.Namespace, config: dict) -> None: sys.exit(1) from .parser import filter_rows_by_date, filter_week_sections + from .status import projected_hours_for_day 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) + # Adjust daily target to account for over/under time earlier this week. + # Only applies when the target date is within the same week as today. + # days_remaining includes today (weekday 0=Mon, so Mon has 5 days remaining). + from datetime import date as _date + + days_remaining = 5 - target_date.weekday() # 1 on Friday, 5 on Monday + if week_start <= _date.today() and target_date.weekday() > 0: + # Sum projected hours for days before today in the week + hours_before_today = sum( + projected_hours_for_day(rows) + for day, rows in day_sections + if day < target_date + ) + effective_daily_target = max( + 0.0, (weekly_target - hours_before_today) / days_remaining + ) + else: + effective_daily_target = daily_target + + day_status = compute_day_status(day_rows, target_date, effective_daily_target) week_status = compute_week_status(day_sections, target_date, weekly_target) print_status(day_status, week_status) +def _cmd_stories(args: argparse.Namespace, config: dict) -> None: + from datetime import timedelta + + target_date, _ = _resolve_date(args) + + if args.weekly: + week_start = target_date - timedelta(days=target_date.weekday()) + day_sections = _resolve_week_sections(args, config, target_date) + rows = [row for _, day_rows in day_sections for row in day_rows] + else: + rows = _resolve_rows(args, config, target_date) + + if not rows: + print("Warning: no timesheet rows found in input.", file=sys.stderr) + return + + project_map = _resolve_project_map(args, config) + print_stories(rows, project_map) + + def _cmd_csv(args: argparse.Namespace, config: dict) -> None: target_date, date_str = _resolve_date(args) rows = _resolve_rows(args, config, target_date) @@ -362,3 +416,5 @@ def main() -> None: _cmd_csv(args, config) elif args.command == "status": _cmd_status(args, config) + elif args.command == "stories": + _cmd_stories(args, config) diff --git a/src/timesheets/output.py b/src/timesheets/output.py index de477a9..c1bbf98 100644 --- a/src/timesheets/output.py +++ b/src/timesheets/output.py @@ -245,6 +245,11 @@ def print_status(day: "DayStatus", week: "WeekStatus") -> None: 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" + is_flex = round(day.daily_target * 60) != 480 + if logged_note and is_flex: + logged_note += " (flex target)" + elif is_flex: + logged_note = "flex target" row("Logged", f"{hh(day.logged_hours)} of {hh(day.daily_target)}", logged_note) row("Remaining today", hh(day.remaining_hours)) @@ -293,3 +298,57 @@ def print_status(day: "DayStatus", week: "WeekStatus") -> None: row("On track", "yes \u2713" if week.on_track else "no \u2717") print() + + +# --------------------------------------------------------------------------- +# Stories output +# --------------------------------------------------------------------------- + + +def print_stories(rows: list[dict], project_map: dict | None = None) -> None: + """ + Print a Markdown list of stories worked on, grouped by project. + + Only rows with a non-empty story field are included. Each story is + deduplicated across the rows; the raw story text (preserving any + markdown link) is used for display. Total time per story is appended. + Project names are resolved through project_map if provided. + """ + # Group by project, collecting (story_raw, total_hours) per story. + # Keyed by (project, stripped_story) to deduplicate; story_raw from + # the first row that has a link is preferred over a plain-text version. + from collections import defaultdict + + _map = project_map or {} + project_order: list[str] = [] + # resolved_label -> {stripped_story -> [story_raw, total_hours]} + stories: dict[str, dict[str, list]] = {} + + for row in rows: + story = row.get("story", "").strip() + if not story: + continue + raw_project = row["project"].strip() + project_label, task = resolve_project_task(raw_project, _map) + label = f"{project_label} / {task}" if task else project_label + story_raw = row.get("story_raw", story).strip() + duration = row.get("duration_hours") or 0.0 + + if label not in stories: + project_order.append(label) + stories[label] = {} + + if story not in stories[label]: + stories[label][story] = [story_raw, 0.0] + else: + # Prefer the version that contains a markdown link + existing_raw = stories[label][story][0] + if "](" in story_raw and "](" not in existing_raw: + stories[label][story][0] = story_raw + + stories[label][story][1] += duration + + for label in project_order: + print(f"- {label}") + for story_raw, total_hours in stories[label].values(): + print(f" - {story_raw} ({decimal_to_hhmm(total_hours)})") diff --git a/src/timesheets/parser.py b/src/timesheets/parser.py index b495f55..dc9319a 100644 --- a/src/timesheets/parser.py +++ b/src/timesheets/parser.py @@ -92,22 +92,15 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]: continue if has_duration_col: - start, end, duration, project, story, note = ( - cells[0], - cells[1], - cells[2], - cells[3], - strip_markdown_link(cells[4]), - strip_markdown_link(cells[5]), - ) + start, end, duration, project = cells[0], cells[1], cells[2], cells[3] + story_raw = cells[4].strip() + story = strip_markdown_link(story_raw) + note = strip_markdown_link(cells[5]) else: - start, end, project, story, note = ( - cells[0], - cells[1], - cells[2], - strip_markdown_link(cells[3]), - strip_markdown_link(cells[4]), - ) + start, end, project = cells[0], cells[1], cells[2] + story_raw = cells[3].strip() + story = strip_markdown_link(story_raw) + note = strip_markdown_link(cells[4]) duration = None if start.lower() == "start": @@ -126,6 +119,7 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]: "duration_hours": None, "project": project, "story": story, + "story_raw": story_raw, "note": note, } ) @@ -148,6 +142,7 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]: "duration_hours": duration_hours, "project": project, "story": story, + "story_raw": story_raw, "note": note, } ) diff --git a/tests/test_output.py b/tests/test_output.py index ec349a3..ecda4b9 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -3,7 +3,7 @@ import io import pytest -from timesheets.output import print_summary, write_csv +from timesheets.output import print_stories, print_summary, write_csv # --------------------------------------------------------------------------- # Shared fixtures @@ -111,3 +111,82 @@ class TestPrintSummary: assert "01:00" in out assert "00:30" in out assert "00:15" in out + + +# --------------------------------------------------------------------------- +# print_stories +# --------------------------------------------------------------------------- + + +def _story_row(project, story, story_raw, hours): + return { + "project": project, + "story": story, + "story_raw": story_raw, + "note": "", + "duration_hours": hours, + } + + +class TestPrintStories: + def test_basic_output(self, capsys): + rows = [_story_row("bugs", "ticket 1", "ticket 1", 1.0)] + print_stories(rows) + out = capsys.readouterr().out + assert "- bugs" in out + assert " - ticket 1" in out + assert "01:00" in out + + def test_rows_without_story_excluded(self, capsys): + rows = [ + _story_row("bugs", "ticket 1", "ticket 1", 1.0), + { + "project": "scrum", + "story": "", + "story_raw": "", + "note": "dsu", + "duration_hours": 0.25, + }, + ] + print_stories(rows) + out = capsys.readouterr().out + assert "scrum" not in out + assert "dsu" not in out + + def test_deduplication_sums_hours(self, capsys): + rows = [ + _story_row("bugs", "ticket 1", "ticket 1", 0.5), + _story_row("bugs", "ticket 1", "ticket 1", 0.5), + ] + print_stories(rows) + out = capsys.readouterr().out + assert out.count("ticket 1") == 1 + assert "01:00" in out + + def test_markdown_link_preserved(self, capsys): + rows = [ + _story_row("bugs", "ticket 1", "ticket 1", 0.5), + _story_row("bugs", "ticket 1", "[ticket 1](:/abc123)", 0.5), + ] + print_stories(rows) + out = capsys.readouterr().out + assert "[ticket 1](:/abc123)" in out + + def test_grouped_by_project(self, capsys): + rows = [ + _story_row("bugs", "ticket 1", "ticket 1", 1.0), + _story_row("rate", "story A", "story A", 2.0), + ] + print_stories(rows) + out = capsys.readouterr().out + assert "- bugs" in out + assert "- rate" in out + bugs_line = next(l for l in out.splitlines() if "bugs" in l) + rate_line = next(l for l in out.splitlines() if "rate" in l) + assert not bugs_line.startswith(" ") # top-level + assert not rate_line.startswith(" ") + + def test_empty_rows(self, capsys): + print_stories([]) + out = capsys.readouterr().out + assert out == ""