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
|
|
@ -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)})")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue