From d2e9641739085a4cf3fea6fb5b84b58ab32ac52e Mon Sep 17 00:00:00 2001 From: "sas.fajri" Date: Thu, 28 May 2026 10:22:37 +0700 Subject: [PATCH] feat: pro-rata berdasarkan time span commit bukan jumlah commit Co-Authored-By: Claude Sonnet 4.6 --- daily_timesheet.py | 80 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 24 deletions(-) diff --git a/daily_timesheet.py b/daily_timesheet.py index a44f05e..d0b0a5a 100755 --- a/daily_timesheet.py +++ b/daily_timesheet.py @@ -3,8 +3,11 @@ Ambil semua commit hari ini dari semua repo, distribusikan 8 jam pro-rata, lalu upload ke Odoo timesheet. -Pro-rata: bobot = jumlah commit per task. Total = 8 jam. -Contoh: Task A 3 commit + Task B 1 commit → A=6h, B=2h. +Pro-rata: bobot = time span per task (last commit - first commit). +Single commit mendapat minimum 30 menit. +Contoh: Task A span 2 jam + Task B span 1.5 jam + Task C 1 commit → + bobot: 120 + 90 + 30 = 240 menit + Task A = 8 × 120/240 = 4.00h, Task B = 3.00h, Task C = 1.00h """ import json @@ -13,7 +16,7 @@ import subprocess import urllib.request import urllib.error import argparse -from datetime import date, timedelta +from datetime import date, datetime, timedelta from collections import defaultdict from decimal import Decimal, ROUND_HALF_UP @@ -153,7 +156,7 @@ def get_commits_today(repo_path: str, author: str, today: str) -> list[dict]: f"--after={today}", f"--before={until}", "--format=%h|%ad|%s", - "--date=short", + "--date=format:%Y-%m-%d %H:%M", ] try: out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True) @@ -165,17 +168,34 @@ def get_commits_today(repo_path: str, author: str, today: str) -> list[dict]: continue parts = line.split("|", 2) if len(parts) == 3: - commits.append({"hash": parts[0], "date": parts[1], "message": parts[2]}) + dt = datetime.strptime(parts[1], "%Y-%m-%d %H:%M") + commits.append({ + "hash": parts[0], + "date": dt.strftime("%Y-%m-%d"), + "time": dt.strftime("%H:%M"), + "dt": dt, + "message": parts[2], + }) return commits # ── Pro-rata ────────────────────────────────────────────────────────────────── +SINGLE_COMMIT_MINUTES = 30 # bobot minimum untuk task dengan 1 commit + + +def span_minutes(timestamps: list[datetime]) -> int: + """Menit antara commit pertama dan terakhir. Minimum 30 menit.""" + if len(timestamps) <= 1: + return SINGLE_COMMIT_MINUTES + return max( + int((max(timestamps) - min(timestamps)).total_seconds() / 60), + SINGLE_COMMIT_MINUTES, + ) + + def distribute_hours(weights: list[int], total: float = 8.0) -> list[float]: - """ - Distribusi jam pro-rata berdasarkan bobot (jumlah commit). - Adjustment di entry terakhir supaya sum persis = total. - """ + """Pro-rata berdasarkan bobot (menit span). Sum = total. Adjustment di entry terakhir.""" total_weight = sum(weights) raw = [Decimal(str(total)) * Decimal(w) / Decimal(total_weight) for w in weights] rounded = [float(r.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) for r in raw] @@ -221,6 +241,8 @@ def main(): "code": m.group(1), "description": m.group(2), "date": c["date"], + "time": c["time"], + "dt": c["dt"], "hash": c["hash"], "repo": repo_name, "project": repo["project"], @@ -238,9 +260,11 @@ def main(): task_cache = {} # (code, project_id) → (task_id, task_name) | None - # Group: key = (task_id, project_id), value = list of commit descriptions & count - groups = defaultdict(lambda: {"descriptions": [], "count": 0, "project_id": None, - "project": None, "task_name": None, "date": None}) + # Group: key = (task_id, project_id) + groups = defaultdict(lambda: { + "descriptions": [], "timestamps": [], "count": 0, + "project_id": None, "project": None, "task_name": None, "date": None, + }) not_found = [] for c in raw_commits: @@ -257,6 +281,7 @@ def main(): task_id, task_name = task key = (task_id, c["project_id"]) groups[key]["descriptions"].append(c["description"]) + groups[key]["timestamps"].append(c["dt"]) groups[key]["count"] += 1 groups[key]["project_id"] = c["project_id"] groups[key]["project"] = c["project"] @@ -268,22 +293,27 @@ def main(): print("\nTidak ada task yang ditemukan di Odoo.") return - # 3. Hitung pro-rata jam + # 3. Hitung pro-rata berdasarkan time span keys = list(groups.keys()) - weights = [groups[k]["count"] for k in keys] - hours = distribute_hours(weights, total=float(TOTAL_HOURS)) + spans = [span_minutes(groups[k]["timestamps"]) for k in keys] + hours = distribute_hours(spans, total=float(TOTAL_HOURS)) # 4. Tampilkan preview - print(f"\n{'─' * 65}") - print(f" {'PROJECT':<20} {'TASK':<10} {'JAM':>5} {'COMMIT':>6} DESKRIPSI") - print(f"{'─' * 65}") + print(f"\n{'─' * 75}") + print(f" {'PROJECT':<20} {'TASK':<10} {'WAKTU':<13} {'SPAN':>6} {'JAM':>5} DESKRIPSI") + print(f"{'─' * 75}") entries = [] - for key, h in zip(keys, hours): + for key, h, span in zip(keys, hours, spans): g = groups[key] - desc = g["descriptions"][0] if len(g["descriptions"]) == 1 else \ - "; ".join(dict.fromkeys(g["descriptions"])) # deduplicate, preserve order - print(f" {g['project']:<20} {str(g['task_id']):<10} {h:>5.2f} {g['count']:>6}x {desc[:35]}") + ts = sorted(g["timestamps"]) + if len(ts) == 1: + waktu = ts[0].strftime("%H:%M") + " (1 commit)" + else: + waktu = f"{ts[0].strftime('%H:%M')}–{ts[-1].strftime('%H:%M')}" + span_label = f"{span}m" if span > SINGLE_COMMIT_MINUTES else "30m*" + desc = "; ".join(dict.fromkeys(g["descriptions"])) + print(f" {g['project']:<20} {str(g['task_id']):<10} {waktu:<13} {span_label:>6} {h:>5.2f} {desc[:30]}") entries.append({ "odoo": { "name": desc, @@ -297,8 +327,10 @@ def main(): "display": g, }) - print(f"{'─' * 65}") - print(f" {'TOTAL':<20} {'':<10} {sum(hours):>5.2f} {sum(weights):>6}x") + print(f"{'─' * 75}") + total_span = sum(spans) + print(f" {'TOTAL':<20} {'':<10} {'':<13} {total_span:>5}m {sum(hours):>5.2f}") + print(f" * single commit → minimum {SINGLE_COMMIT_MINUTES} menit") if not_found: print(f"\n {len(not_found)} commit diabaikan (task tidak ditemukan di Odoo)")