print_summary_weekly, print_summary_weekly_totals and _aggregate summed raw duration_hours, which is None for open (unfinished) entries, raising TypeError: float + NoneType. Skip open entries from the weekly totals, matching to_csv_entries and parser.aggregate. Add regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
409 lines
14 KiB
Python
409 lines
14 KiB
Python
import csv
|
|
import sys
|
|
from collections import OrderedDict
|
|
from datetime import date
|
|
from typing import IO, TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from .status import DayStatus, WeekStatus
|
|
|
|
from .projects import resolve_project_task
|
|
from .utils import decimal_to_hhmm
|
|
|
|
|
|
def to_csv_entries(rows: list[dict]) -> list[dict]:
|
|
"""Convert raw parsed rows to write_csv-compatible entries without aggregating.
|
|
|
|
Each row becomes its own entry. Open entries (duration_hours is None) are
|
|
skipped. Rows are not combined, even if they share the same project and
|
|
description.
|
|
"""
|
|
from .parser import build_description
|
|
|
|
return [
|
|
{
|
|
"project": row["project"].strip(),
|
|
"description": build_description(row["story"], row["note"]),
|
|
"quantity": row["duration_hours"],
|
|
}
|
|
for row in rows
|
|
if row["duration_hours"] is not None and not row.get("skip_csv")
|
|
]
|
|
|
|
|
|
def write_csv_weekly(
|
|
day_sections: list[tuple[str, list[dict]]],
|
|
output: IO[str],
|
|
project_map: dict,
|
|
) -> None:
|
|
"""Write entries from multiple days as a single CSV with one header row.
|
|
|
|
day_sections is a list of (date_str, entries) pairs where entries are
|
|
already write_csv-compatible (project, description, quantity).
|
|
"""
|
|
writer = csv.writer(output)
|
|
writer.writerow(["Date*", "Project*", "Task", "Description", "Quantity"])
|
|
for date_str, entries in day_sections:
|
|
for entry in entries:
|
|
project, task = resolve_project_task(entry["project"], project_map)
|
|
writer.writerow(
|
|
[
|
|
date_str,
|
|
project,
|
|
task,
|
|
entry["description"],
|
|
f"{entry['quantity']:.2f}",
|
|
]
|
|
)
|
|
|
|
|
|
def write_csv(
|
|
aggregated: list[dict],
|
|
output: IO[str],
|
|
date_str: str,
|
|
project_map: dict,
|
|
) -> None:
|
|
"""Write the aggregated timesheet data as CSV."""
|
|
writer = csv.writer(output)
|
|
writer.writerow(["Date*", "Project*", "Task", "Description", "Quantity"])
|
|
for entry in aggregated:
|
|
project, task = resolve_project_task(entry["project"], project_map)
|
|
writer.writerow(
|
|
[
|
|
date_str,
|
|
project,
|
|
task,
|
|
entry["description"],
|
|
f"{entry['quantity']:.2f}",
|
|
]
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Summary helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _group_by_project_label(
|
|
aggregated: list[dict], project_map: dict
|
|
) -> "OrderedDict[str, list[dict]]":
|
|
"""Group aggregated entries by resolved project/task label."""
|
|
grouped: OrderedDict[str, list[dict]] = OrderedDict()
|
|
for entry in aggregated:
|
|
project, task = resolve_project_task(entry["project"], project_map)
|
|
label = f"{project} / {task}" if task else project
|
|
grouped.setdefault(label, []).append(entry)
|
|
return grouped
|
|
|
|
|
|
def _sum_by_project_label(
|
|
aggregated: list[dict], project_map: dict
|
|
) -> "OrderedDict[str, float]":
|
|
"""Return total hours per project label."""
|
|
totals: OrderedDict[str, float] = OrderedDict()
|
|
for entry in aggregated:
|
|
project, task = resolve_project_task(entry["project"], project_map)
|
|
label = f"{project} / {task}" if task else project
|
|
totals[label] = totals.get(label, 0.0) + entry["quantity"]
|
|
return totals
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Full detail summary (default)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def print_summary(aggregated: list[dict], project_map: dict) -> None:
|
|
"""Print a full human-readable summary with per-entry descriptions."""
|
|
grouped = _group_by_project_label(aggregated, project_map)
|
|
total_all = sum(e["quantity"] for e in aggregated)
|
|
|
|
all_descs = [e["description"] for e in aggregated]
|
|
desc_width = max(max((len(d) for d in all_descs), default=40), 40)
|
|
separator = "-" * (desc_width + 16)
|
|
|
|
for label, entries in grouped.items():
|
|
project_total = sum(e["quantity"] for e in entries)
|
|
print(f"\n {label} ({decimal_to_hhmm(project_total)})")
|
|
print(" " + separator)
|
|
for entry in entries:
|
|
print(
|
|
f" {entry['description']:<{desc_width}} {decimal_to_hhmm(entry['quantity'])}"
|
|
)
|
|
print(" " + separator)
|
|
|
|
print(f"\n {'TOTAL':<{desc_width + 2}} {decimal_to_hhmm(total_all)}\n")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Short summary (-s): one line per project label
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def print_summary_short(aggregated: list[dict], project_map: dict) -> None:
|
|
"""Print one line per project label with total hours."""
|
|
totals = _sum_by_project_label(aggregated, project_map)
|
|
total_all = sum(totals.values())
|
|
|
|
label_width = max((len(l) for l in totals), default=20)
|
|
label_width = max(label_width, 20)
|
|
|
|
for label, hours in totals.items():
|
|
print(f" {label:<{label_width}} {decimal_to_hhmm(hours)}")
|
|
|
|
print(f" {'TOTAL':<{label_width}} {decimal_to_hhmm(total_all)}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Weekly summary (--weekly)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def print_summary_weekly(
|
|
day_sections: list[tuple[date, list[dict]]], project_map: dict
|
|
) -> None:
|
|
"""Print a full summary for each day of the week."""
|
|
week_total = sum(
|
|
e["duration_hours"]
|
|
for _, rows in day_sections
|
|
for e in rows
|
|
if e["duration_hours"] is not None
|
|
)
|
|
for day, rows in day_sections:
|
|
aggregated = _aggregate(rows)
|
|
day_total = sum(e["quantity"] for e in aggregated)
|
|
print(f"\n{'═' * 60}")
|
|
print(f" {day.strftime('%A, %Y-%m-%d')} ({decimal_to_hhmm(day_total)})")
|
|
print(f"{'═' * 60}")
|
|
print_summary(aggregated, project_map)
|
|
|
|
print(f"\n {'WEEK TOTAL':<42} {decimal_to_hhmm(week_total)}\n")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Weekly short (-s --weekly): per-day project totals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def print_summary_weekly_short(
|
|
day_sections: list[tuple[date, list[dict]]], project_map: dict
|
|
) -> None:
|
|
"""Print one line per project per day, plus day and week totals."""
|
|
week_total = 0.0
|
|
all_labels: list[str] = []
|
|
day_data: list[tuple[date, OrderedDict[str, float]]] = []
|
|
|
|
for day, rows in day_sections:
|
|
aggregated = _aggregate(rows)
|
|
totals = _sum_by_project_label(aggregated, project_map)
|
|
day_data.append((day, totals))
|
|
all_labels.extend(totals.keys())
|
|
|
|
label_width = max((len(l) for l in all_labels), default=20)
|
|
label_width = max(label_width, 20)
|
|
|
|
for day, totals in day_data:
|
|
day_total = sum(totals.values())
|
|
week_total += day_total
|
|
print(f"\n {day.strftime('%A, %Y-%m-%d')}")
|
|
for label, hours in totals.items():
|
|
print(f" {label:<{label_width}} {decimal_to_hhmm(hours)}")
|
|
print(f" {'day total':<{label_width}} {decimal_to_hhmm(day_total)}")
|
|
|
|
print(f"\n {'WEEK TOTAL':<{label_width + 2}} {decimal_to_hhmm(week_total)}\n")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Weekly day-total-only (-ss --weekly): one line per day
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def print_summary_weekly_totals(
|
|
day_sections: list[tuple[date, list[dict]]], project_map: dict
|
|
) -> None:
|
|
"""Print only the total hours per day plus a week total."""
|
|
week_total = 0.0
|
|
rows_out = []
|
|
|
|
for day, rows in day_sections:
|
|
day_total = sum(
|
|
e["duration_hours"] for e in rows if e["duration_hours"] is not None
|
|
)
|
|
week_total += day_total
|
|
rows_out.append((day.strftime("%A"), day.strftime("%Y-%m-%d"), day_total))
|
|
|
|
day_width = max((len(r[0]) for r in rows_out), default=9)
|
|
for day_name, date_str, hours in rows_out:
|
|
print(f" {day_name:<{day_width}}, {date_str} {decimal_to_hhmm(hours)}")
|
|
print(f" {'WEEK TOTAL':<{day_width + 13}} {decimal_to_hhmm(week_total)}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _aggregate(rows: list[dict]) -> list[dict]:
|
|
"""Aggregate raw rows — thin wrapper to avoid importing parser in output."""
|
|
from collections import defaultdict
|
|
|
|
from .parser import build_description
|
|
|
|
key_order: list[tuple] = []
|
|
totals: dict[tuple, float] = defaultdict(float)
|
|
for row in rows:
|
|
if row["duration_hours"] is None:
|
|
continue
|
|
description = build_description(row["story"], row["note"])
|
|
key = (row["project"].strip(), description)
|
|
if key not in totals:
|
|
key_order.append(key)
|
|
totals[key] += row["duration_hours"]
|
|
|
|
return [
|
|
{"project": p, "description": d, "quantity": totals[(p, d)]}
|
|
for p, d in key_order
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Status output
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def print_status(day: "DayStatus", week: "WeekStatus") -> None:
|
|
"""Print the status dashboard to stdout."""
|
|
hh = decimal_to_hhmm
|
|
W = 42 # label column width
|
|
|
|
def row(label: str, value: str, note: str = "") -> None:
|
|
suffix = f" ({note})" if note else ""
|
|
print(f" {label:<{W - 2}} {value}{suffix}")
|
|
|
|
def sep(char: str = "-", width: int = W + 12) -> None:
|
|
print(" " + char * width)
|
|
|
|
# ---- Today ----
|
|
print(f"\n Today {day.target_date.strftime('%A, %Y-%m-%d')}")
|
|
sep()
|
|
|
|
start_str = day.day_start.strftime("%H:%M") if day.day_start else "?"
|
|
row("Started", start_str)
|
|
|
|
logged_note = ""
|
|
if day.has_open_entry:
|
|
open_str = (
|
|
day.open_entry_start.strftime("%H:%M") if day.open_entry_start else "?"
|
|
)
|
|
logged_note = f"open entry since {open_str}"
|
|
if day.future_hours > 0:
|
|
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))
|
|
|
|
if day.expected_end is not None:
|
|
if day.remaining_hours == 0:
|
|
note = "done"
|
|
elif day.latest_end_exceeds_expected:
|
|
note = "pre-logged entry ends later than needed"
|
|
else:
|
|
note = ""
|
|
row("Expected end", day.expected_end.strftime("%H:%M"), note)
|
|
else:
|
|
row("Expected end", "unknown")
|
|
|
|
# ---- Week ----
|
|
print(
|
|
f"\n Week W{day.target_date.isocalendar()[1]:02d} "
|
|
f"({week.week_start.strftime('%d/%m')} \u2013 {week.week_end.strftime('%d/%m')})"
|
|
)
|
|
sep()
|
|
|
|
row(
|
|
"Logged this week",
|
|
f"{hh(week.total_logged)} of {hh(week.weekly_target)}",
|
|
)
|
|
row("Remaining this week", hh(week.remaining_hours))
|
|
|
|
if week.daily_average is not None:
|
|
row(
|
|
"Daily average",
|
|
hh(week.daily_average),
|
|
f"{week.days_worked} day{'s' if week.days_worked != 1 else ''} worked",
|
|
)
|
|
|
|
if week.projected_total is not None:
|
|
deficit = week.weekly_target - week.projected_total
|
|
if deficit > 0.05:
|
|
note = f"{hh(deficit)} short"
|
|
elif deficit < -0.05:
|
|
note = f"{hh(abs(deficit))} over"
|
|
else:
|
|
note = "exact"
|
|
row("Projected week total", hh(week.projected_total), note)
|
|
|
|
if week.on_track is not 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)})")
|