#!/usr/bin/env python3 """Format timesheet completion data into a markdown report.""" from __future__ import annotations import argparse import json import sys from datetime import datetime, timezone from pathlib import Path from typing import Any def fmt_h(hours: float) -> str: """Format hours with one decimal place, dropping .0 for whole numbers.""" if hours == int(hours): return f"{int(hours)}h" return f"{hours:.1f}h" def completion_badge(pct: float) -> str: if pct >= 100: return "✓" if pct >= 75: return "" if pct >= 50: return "⚠" return "⚠⚠" def build_report(data: dict[str, Any]) -> str: period = data["period"] summary = data["summary"] employees = data["employees"] generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") n = summary["total_employees"] per_person = fmt_h(summary["total_expected_hours"] / n) if n else "0h" wdays_by_country: dict[str, int] = period.get("working_days_by_country", {}) if len(wdays_by_country) == 1: country, wdays = next(iter(wdays_by_country.items())) wdays_line = f"Working days: {wdays} ({country} calendar, public holidays excluded)" else: parts = ", ".join(f"{c}: {d}d" for c, d in sorted(wdays_by_country.items())) wdays_line = f"Working days by country: {parts} (public holidays excluded)" lines = [ "# Timesheet Completion Report", "", f"Generated: {generated_at}", f"Period: {period['label'].capitalize()} ({period['start']} to {period['end']})", wdays_line, "", "## Summary", "", f"- Expected: **{fmt_h(summary['total_expected_hours'])}** ({n} employees × {per_person} avg)", f"- Reported: **{fmt_h(summary['total_reported_hours'])}**", f"- Overall completion: **{summary['overall_completion_pct']}%**", f"- Fully complete: {summary['employees_fully_complete']} of {n} employees", f"- With gaps: {summary['employees_with_gaps']} employees", "", "## Employees (ranked by missing hours)", "", "| Employee | Expected | Reported | Completion | Missing |", "| --- | ---: | ---: | ---: | ---: |", ] # Determine the most common working-day count to detect deviations max_wdays = max(wdays_by_country.values()) if wdays_by_country else 0 for emp in employees: badge = completion_badge(emp["completion_pct"]) missing = fmt_h(emp["missing_hours"]) if emp["missing_hours"] > 0 else "—" pct_str = f"{emp['completion_pct']}% {badge}".strip() name = emp["name"] notes = [] eff = emp.get("effective_working_days", max_wdays) if emp.get("start_date") and eff < max_wdays: notes.append(f"from {emp['start_date']}") if emp.get("termination_date") and eff < max_wdays: notes.append(f"until {emp['termination_date']}") if len(wdays_by_country) > 1: notes.append(emp.get("country", "")) if notes: name += f" _({', '.join(n for n in notes if n)})_" lines.append( f"| {name} | {fmt_h(emp['expected_hours'])} " f"| {fmt_h(emp['reported_hours'])} | {pct_str} | {missing} |" ) out_of_range = [ emp for emp in employees if emp.get("hours_before_start", 0) > 0 or emp.get("hours_after_termination", 0) > 0 ] if out_of_range: lines += [ "", "## Hours logged outside employment period", "", "These hours are excluded from the completion calculation above.", "", "| Employee | Before start date | After termination date |", "| --- | ---: | ---: |", ] for emp in out_of_range: before = fmt_h(emp["hours_before_start"]) if emp.get("hours_before_start", 0) > 0 else "—" after = fmt_h(emp["hours_after_termination"]) if emp.get("hours_after_termination", 0) > 0 else "—" lines.append(f"| {emp['name']} | {before} | {after} |") countries_str = ", ".join(sorted(wdays_by_country.keys())) lines += [ "", "---", "", f"_Working days source: [date.nager.at](https://date.nager.at) ({countries_str})._", "_Hours source: Tempo._", ] return "\n".join(lines) + "\n" def run(args: argparse.Namespace) -> int: data_path = Path(args.data) if not data_path.exists(): print(f"Error: {data_path} not found. Run preprocess.py first.", file=sys.stderr) return 1 data = json.loads(data_path.read_text(encoding="utf-8")) output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) report = build_report(data) report_path = output_dir / "timesheet_report.md" report_path.write_text(report, encoding="utf-8") print(f"Wrote {report_path}") return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Format timesheet completion data into a markdown report." ) parser.add_argument( "--data", required=True, help="Path to timesheet_data.json produced by preprocess.py.", ) parser.add_argument( "--output-dir", required=True, help="Directory for timesheet_report.md.", ) return parser if __name__ == "__main__": sys.exit(run(build_parser().parse_args()))