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

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