From bb918a8839319d03d173b671fc3760e95815eb7e Mon Sep 17 00:00:00 2001 From: Floke Date: Mon, 26 Jan 2026 18:39:51 +0000 Subject: [PATCH] feat(timetracking): Implement automatic time tracking for dev sessions This commit introduces a time tracking feature for development sessions managed by . Key changes: - **Extended Notion Helpers:** Added new functions (get_page_number_property, get_page_date_property) to read numerical and date values from Notion. Refactored the status update function to a generic to handle various field types. - **Session Start Time:** A timestamp (session_start_time) is now saved in the file when a session starts. - **Automatic Time Calculation:** The time elapsed since is calculated when the command is executed. - **Incremental Duration:** The calculated duration is added to the existing 'Total Duration h' property of the Notion task, and the result is written back to Notion. - **Continuous Tracking:** The is reset to the current time after each report to accurately track the next work unit in the same session. Fixes [2f488f42] --- .dev_session/SESSION_INFO | 2 +- dev_session.py | 170 +++++++++++++++++++++++++++++--------- 2 files changed, 132 insertions(+), 40 deletions(-) diff --git a/.dev_session/SESSION_INFO b/.dev_session/SESSION_INFO index 39300e3e..6d76738d 100644 --- a/.dev_session/SESSION_INFO +++ b/.dev_session/SESSION_INFO @@ -1 +1 @@ -{"task_id": "2f488f42-8544-819a-8407-f29748b3e0b8", "token": "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"} \ No newline at end of file +{"task_id": "2f488f42-8544-81ac-a9f8-e373c4c18115", "token": "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", "session_start_time": "2026-01-26T18:39:11.157549"} \ No newline at end of file diff --git a/dev_session.py b/dev_session.py index 0dad8ba9..cd51b8ae 100644 --- a/dev_session.py +++ b/dev_session.py @@ -1,10 +1,14 @@ +import re +from typing import List, Dict, Optional, Tuple, Any +from getpass import getpass +from dotenv import load_dotenv +import subprocess +from datetime import datetime, timedelta import os import requests import json -import re -from typing import List, Dict, Optional, Tuple -from getpass import getpass -from dotenv import load_dotenv +import argparse +import shutil load_dotenv() @@ -83,18 +87,48 @@ def get_page_title(page: Dict) -> str: return title_parts[0].get("plain_text", "Unbenannt") return "Unbenannt" -def get_page_property(page: Dict, prop_name: str, prop_type: str = "rich_text") -> Optional[str]: +def get_page_rich_text_property(page: Dict, prop_name: str) -> Optional[str]: """Extrahiert den Inhalt einer bestimmten Eigenschaft (Property) von einer Seite.""" prop = page.get("properties", {}).get(prop_name) if not prop: return None - if prop_type == "rich_text" and prop.get("type") == "rich_text": + if prop.get("type") == "rich_text": text_parts = prop.get("rich_text", []) if text_parts: return text_parts[0].get("plain_text") - # Hier könnten weitere Typen wie 'select', 'number' etc. behandelt werden + return None + +def get_page_number_property(page: Dict, prop_name: str) -> Optional[float]: + """Extrahiert den Inhalt einer Number-Eigenschaft von einer Seite.""" + prop = page.get("properties", {}).get(prop_name) + if not prop: + return None + + if prop.get("type") == "number": + return prop.get("number") + + return None + +def get_page_date_property(page: Dict, prop_name: str) -> Optional[datetime]: + """Extrahiert den Inhalt einer Date-Eigenschaft von einer Seite.""" + prop = page.get("properties", {}).get(prop_name) + if not prop: + return None + + if prop.get("type") == "date": + date_info = prop.get("date") + if date_info and date_info.get("start"): + try: + # Notion gibt Datumszeiten im ISO 8601 Format zurück (z.B. '2023-10-27T10:00:00.000+00:00') + # oder nur Datum (z.B. '2023-10-27') + # datetime.fromisoformat kann beides verarbeiten, aber Zeitzonen können komplex sein. + # Für unsere Zwecke reicht es, wenn es als UTC betrachtet wird und wir die Dauer berechnen. + return datetime.fromisoformat(date_info["start"].replace('Z', '+00:00')) + except ValueError: + return None + return None def get_page_content(token: str, page_id: str) -> str: @@ -193,32 +227,42 @@ def get_database_status_options(token: str, db_id: str) -> List[str]: print(f"Fehler beim Abrufen der Datenbank-Eigenschaften: {e}") return [] -def update_notion_task_status(token: str, task_id: str, status_value: str = "Doing") -> bool: - """Aktualisiert den Status eines Notion-Tasks.""" - print(f"\n--- Aktualisiere Status von Task '{task_id}' auf '{status_value}'... ---") +def update_notion_task_property(token: str, task_id: str, prop_name: str, prop_value: Any, prop_type: str) -> bool: + """Aktualisiert eine bestimmte Eigenschaft (Property) eines Notion-Tasks.""" + print(f"\n--- Aktualisiere Task '{task_id}', Eigenschaft '{prop_name}' ({prop_type}) mit Wert '{prop_value}'... ---") url = f"https://api.notion.com/v1/pages/{task_id}" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", "Notion-Version": "2022-06-28" } - payload = { - "properties": { - "Status": { - "status": { - "name": status_value - } - } - } - } + + payload_properties = {} + if prop_type == "status": + payload_properties[prop_name] = {"status": {"name": prop_value}} + elif prop_type == "number": + payload_properties[prop_name] = {"number": prop_value} + elif prop_type == "date": + # Notion erwartet Datum im ISO 8601 Format + if isinstance(prop_value, datetime): + payload_properties[prop_name] = {"date": {"start": prop_value.isoformat()}} + else: + print(f"❌ FEHLER: Ungültiges Datumformat für '{prop_name}'. Erwartet datetime-Objekt.") + return False + # Weitere Typen können hier hinzugefügt werden (z.B. rich_text, multi_select etc.) + else: + print(f"❌ FEHLER: Nicht unterstützter Eigenschaftstyp '{prop_type}' für Update.") + return False + + payload = {"properties": payload_properties} try: response = requests.patch(url, headers=headers, json=payload) response.raise_for_status() - print(f"✅ Task-Status erfolgreich auf '{status_value}' aktualisiert.") + print(f"✅ Task-Eigenschaft '{prop_name}' erfolgreich aktualisiert.") return True except requests.exceptions.RequestException as e: - print(f"❌ FEHLER beim Aktualisieren des Task-Status: {e}") + print(f"❌ FEHLER beim Aktualisieren der Task-Eigenschaft '{prop_name}': {e}") try: print(f"Antwort des Servers: {json.dumps(e.response.json(), indent=2)}") except: @@ -395,7 +439,7 @@ def select_project(token: str) -> Optional[Tuple[Dict, Optional[str]]]: choice = int(input("Bitte wähle eine Nummer: ")) if 1 <= choice <= len(projects): selected_project = projects[choice - 1] - readme_path = get_page_property(selected_project, "Readme Path") + readme_path = get_page_rich_text_property(selected_project, "Readme Path") return selected_project, readme_path else: print("Ungültige Auswahl.") @@ -491,13 +535,68 @@ def report_status_to_notion( session_data = json.load(f) task_id = session_data.get("task_id") token = session_data.get("token") + session_start_time_str = session_data.get("session_start_time") - if not (task_id and token): - print("❌ FEHLER: Session-Daten unvollständig. Kann keinen Statusbericht erstellen.") + if not (task_id and token and session_start_time_str): + print("❌ FEHLER: Session-Daten unvollständig oder Startzeit fehlt. Kann keinen Statusbericht erstellen.") + return + + try: + session_start_time = datetime.fromisoformat(session_start_time_str) + except ValueError: + print(f"❌ FEHLER: Ungültiges Startzeitformat in Session-Daten: {session_start_time_str}") return print(f"--- Erstelle Statusbericht für Task {task_id} ---") + tasks_db_id = find_database_by_title(token, "Tasks [UT]") + if not tasks_db_id: + return + + # 1. Aktuelles Task-Objekt abrufen, um die vorhandene Dauer zu lesen + page_url = f"https://api.notion.com/v1/pages/{task_id}" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Notion-Version": "2022-06-28" + } + current_task_page = None + try: + response = requests.get(page_url, headers=headers) + response.raise_for_status() + current_task_page = response.json() + except requests.exceptions.RequestException as e: + print(f"❌ FEHLER beim Abrufen der Task-Seite {task_id}: {e}") + return + + if not current_task_page: + print(f"❌ FEHLER: Konnte den Task {task_id} in Notion nicht finden. Zeiterfassung wird übersprungen.") + return + + # 2. Bestehende Total Duration (h) abrufen + existing_duration = get_page_number_property(current_task_page, "Total Duration (h)") + if existing_duration is None: + existing_duration = 0.0 + + # 3. Dauer der aktuellen Arbeitseinheit berechnen + current_end_time = datetime.now() + time_spent = (current_end_time - session_start_time).total_seconds() / 3600.0 # Dauer in Stunden + + # 4. Neue Gesamtdauer berechnen + new_total_duration = existing_duration + time_spent + + # 5. Total Duration (h) in Notion aktualisieren + update_notion_task_property(token, task_id, "Total Duration (h)", round(new_total_duration, 2), "number") + + # 6. session_start_time in SESSION_INFO für die nächste Arbeitseinheit aktualisieren + # Hier speichern wir die Endzeit als neue Startzeit für die nächste mögliche Einheit + # oder löschen sie, wenn die Session als beendet betrachtet wird. + # Entsprechend des Plans setzen wir sie zurück (auf die aktuelle Endzeit der Arbeitseinheit). + session_data["session_start_time"] = current_end_time.isoformat() + with open(SESSION_FILE_PATH, "w") as f: + json.dump(session_data, f) + + # Git-Zusammenfassung generieren (immer, wenn nicht explizit überschrieben) actual_git_changes = git_changes_override actual_commit_messages = commit_messages_override @@ -601,7 +700,7 @@ def report_status_to_notion( # Notion aktualisieren append_blocks_to_notion_page(token, task_id, notion_blocks) - update_notion_task_status(token, task_id, actual_status) + update_notion_task_property(token, task_id, "Status", actual_status, "status") except (FileNotFoundError, json.JSONDecodeError) as e: print(f"❌ FEHLER beim Lesen der Session-Informationen für Statusbericht: {e}") @@ -641,20 +740,13 @@ def generate_cli_context(project_title: str, task_title: str, task_id: str, read # Die start_gemini_cli Funktion wird entfernt, da das aufrufende Skript jetzt die Gemini CLI startet. -import shutil -import argparse - -# --- Session Management --- - -SESSION_DIR = ".dev_session" -SESSION_FILE_PATH = os.path.join(SESSION_DIR, "SESSION_INFO") - -def save_session_info(task_id: str, token: str): - """Speichert die Task-ID und den Token für den Git-Hook.""" +def save_session_info(task_id: str, token: str, session_start_time: datetime): + """Speichert die Task-ID, den Token und die Startzeit der Session.""" os.makedirs(SESSION_DIR, exist_ok=True) session_data = { "task_id": task_id, - "token": token + "token": token, + "session_start_time": session_start_time.isoformat() # Speichern als ISO-Format String } with open(SESSION_FILE_PATH, "w") as f: json.dump(session_data, f) @@ -713,7 +805,7 @@ def complete_session(): status_options = get_database_status_options(token, tasks_db_id) if status_options: done_status = status_options[-1] - update_notion_task_status(token, task_id, done_status) + update_notion_task_property(token, task_id, "Status", done_status, "status") except (FileNotFoundError, json.JSONDecodeError): print("Fehler beim Lesen der Session-Informationen.") @@ -771,7 +863,7 @@ def start_interactive_session(): task_description = get_page_content(token, task_id) # Session-Informationen für den Git-Hook speichern - save_session_info(task_id, token) + save_session_info(task_id, token, datetime.now()) # Git-Hook installieren, der die Session-Infos nutzt install_git_hook() @@ -782,7 +874,7 @@ def start_interactive_session(): suggested_branch_name = f"feature/task-{task_id.split('-')[0]}-{title_slug}" - status_updated = update_notion_task_status(token, task_id, "Doing") + status_updated = update_notion_task_property(token, task_id, "Status", "Doing", "status") if not status_updated: print("Warnung: Notion-Task-Status konnte nicht aktualisiert werden.")