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:
Jef Roosens 2026-05-26 10:31:35 +02:00
parent f372a691d4
commit 2d60624e0e
Signed by: Jef Roosens
GPG key ID: 119385BCAA005C21
5 changed files with 206 additions and 17 deletions

BIN
.coverage

Binary file not shown.

View file

@ -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)

View file

@ -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)})")

View file

@ -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,
}
)

View file

@ -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 == ""