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]
This commit is contained in:
2026-01-26 18:39:51 +00:00
parent 4d26cca645
commit a01a6e5c7d
2 changed files with 132 additions and 40 deletions

View File

@@ -1 +1 @@
{"task_id": "2f488f42-8544-819a-8407-f29748b3e0b8", "token": "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"}
{"task_id": "2f488f42-8544-81ac-a9f8-e373c4c18115", "token": "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", "session_start_time": "2026-01-26T18:39:11.157549"}

View File

@@ -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.")