skills-marketplace/plugins/ivona/skills/timesheet-checker/scripts/postprocess.py
Michał Kopeć 576a14c82f Add ivona and timesheet plugins, MIT LICENSE, and invoice-prep-test
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.
2026-07-15 15:00:21 +02:00

156 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()))