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 (
|
from .output import (
|
||||||
print_status,
|
print_status,
|
||||||
|
print_stories,
|
||||||
print_summary,
|
print_summary,
|
||||||
print_summary_short,
|
print_summary_short,
|
||||||
print_summary_weekly,
|
print_summary_weekly,
|
||||||
|
|
@ -135,6 +136,19 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
)
|
)
|
||||||
_add_shared_args(status_parser)
|
_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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -319,16 +333,56 @@ def _cmd_status(args: argparse.Namespace, config: dict) -> None:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
from .parser import filter_rows_by_date, filter_week_sections
|
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_rows = filter_rows_by_date(lines, target_date)
|
||||||
day_sections = filter_week_sections(lines, week_start)
|
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)
|
week_status = compute_week_status(day_sections, target_date, weekly_target)
|
||||||
|
|
||||||
print_status(day_status, week_status)
|
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:
|
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)
|
||||||
|
|
@ -362,3 +416,5 @@ def main() -> None:
|
||||||
_cmd_csv(args, config)
|
_cmd_csv(args, config)
|
||||||
elif args.command == "status":
|
elif args.command == "status":
|
||||||
_cmd_status(args, config)
|
_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"
|
logged_note += f", {hh(day.future_hours)} pre-logged ahead"
|
||||||
elif day.future_hours > 0:
|
elif day.future_hours > 0:
|
||||||
logged_note = f"{hh(day.future_hours)} pre-logged ahead"
|
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("Logged", f"{hh(day.logged_hours)} of {hh(day.daily_target)}", logged_note)
|
||||||
row("Remaining today", hh(day.remaining_hours))
|
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")
|
row("On track", "yes \u2713" if week.on_track else "no \u2717")
|
||||||
|
|
||||||
print()
|
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
|
continue
|
||||||
|
|
||||||
if has_duration_col:
|
if has_duration_col:
|
||||||
start, end, duration, project, story, note = (
|
start, end, duration, project = cells[0], cells[1], cells[2], cells[3]
|
||||||
cells[0],
|
story_raw = cells[4].strip()
|
||||||
cells[1],
|
story = strip_markdown_link(story_raw)
|
||||||
cells[2],
|
note = strip_markdown_link(cells[5])
|
||||||
cells[3],
|
|
||||||
strip_markdown_link(cells[4]),
|
|
||||||
strip_markdown_link(cells[5]),
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
start, end, project, story, note = (
|
start, end, project = cells[0], cells[1], cells[2]
|
||||||
cells[0],
|
story_raw = cells[3].strip()
|
||||||
cells[1],
|
story = strip_markdown_link(story_raw)
|
||||||
cells[2],
|
note = strip_markdown_link(cells[4])
|
||||||
strip_markdown_link(cells[3]),
|
|
||||||
strip_markdown_link(cells[4]),
|
|
||||||
)
|
|
||||||
duration = None
|
duration = None
|
||||||
|
|
||||||
if start.lower() == "start":
|
if start.lower() == "start":
|
||||||
|
|
@ -126,6 +119,7 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]:
|
||||||
"duration_hours": None,
|
"duration_hours": None,
|
||||||
"project": project,
|
"project": project,
|
||||||
"story": story,
|
"story": story,
|
||||||
|
"story_raw": story_raw,
|
||||||
"note": note,
|
"note": note,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -148,6 +142,7 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]:
|
||||||
"duration_hours": duration_hours,
|
"duration_hours": duration_hours,
|
||||||
"project": project,
|
"project": project,
|
||||||
"story": story,
|
"story": story,
|
||||||
|
"story_raw": story_raw,
|
||||||
"note": note,
|
"note": note,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import io
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from timesheets.output import print_summary, write_csv
|
from timesheets.output import print_stories, print_summary, write_csv
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared fixtures
|
# Shared fixtures
|
||||||
|
|
@ -111,3 +111,82 @@ class TestPrintSummary:
|
||||||
assert "01:00" in out
|
assert "01:00" in out
|
||||||
assert "00:30" in out
|
assert "00:30" in out
|
||||||
assert "00:15" 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