Adds ivona (invoice-checker, timesheet-checker) and timesheet (check-my-timesheet, invoice-prep) as new plugins, plus invoice-prep-test — a one-off experiment that requests the Tempo API token via plugin userConfig at install time instead of a .env file, to check whether that value actually reaches the skill's script. Also adds the root LICENSE (MIT) referenced from the README.
156 lines
5.4 KiB
Python
156 lines
5.4 KiB
Python
#!/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()))
|