feat(stories): add stories subcommand
- Add stories subcommand listing stories worked on, grouped by project - Preserve story_raw in parser row dicts alongside the stripped story, so markdown links are available for display - print_stories() filters to rows with a non-empty story field, deduplicates by stripped story text (preferring the linked version), sums hours per story, and outputs an indented Markdown list - Project names resolved through project_map (same as csv/summary) - -w/--weekly flag aggregates stories across the full week - Add tests for print_stories covering deduplication, link preservation, grouping, empty rows, and story-less row exclusion - Fix flex daily target in status: use projected hours per prior day rather than fixed 8h when computing remaining hours for today
This commit is contained in:
parent
f372a691d4
commit
2d60624e0e
5 changed files with 206 additions and 17 deletions
BIN
.coverage
BIN
.coverage
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)})")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 == ""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue