Jelajahi Sumber

Improve --dry-run table output with colours, legend and week separators

- Duration column now shows Xh Ymin instead of raw minutes
- Colour-code duration: green >= 8 h, red > 10 h
- Colour-code date column orange when the ISO-week total exceeds 48 h
- Print a dotted separator line between rows from different ISO weeks
- Print a colour legend above the table
- Update README, TECHNISCHE_DOKUMENTATION and Design Document accordingly
- config.json: update project code NII001 → NII002

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Marius 2 minggu lalu
induk
melakukan
11592f6580
5 mengubah file dengan 99 tambahan dan 18 penghapusan
  1. 1 1
      Design Document v1.1.md
  2. 8 1
      README.md
  3. 5 1
      TECHNISCHE_DOKUMENTATION.md
  4. 1 1
      config.json
  5. 84 14
      notion_to_calendar.py

+ 1 - 1
Design Document v1.1.md

@@ -726,7 +726,7 @@ __pycache__/
 
 ### Szenario 5 – `--dry-run`
 - **Input:** `--monat 2026-04 --dry-run`
-- **Erwartung:** Tabellarische Ausgabe aller Events mit Zustand (NEU / GEÄNDERT / UNVERÄNDERT), kein AppleScript ausgeführt, sync_state.json unverändert
+- **Erwartung:** Tabellarische Ausgabe aller Events mit Zustand (NEU / GEÄNDERT / UNVERÄNDERT), kein AppleScript ausgeführt, sync_state.json unverändert. Oberhalb der Tabelle erscheint eine Legende. Farbkodierung: Spalte „Dauer" grün bei ≥ 480 min (8 h), rot bei > 600 min (10 h); Spalte „Datum" orange für alle Tage einer ISO-Woche, deren Gesamtminuten > 2880 (48 h).
 
 ### Szenario 6 – Fehlende Pflichtfelder
 - **Input:** Notion-Seite ohne `Last Edit`

+ 8 - 1
README.md

@@ -164,7 +164,14 @@ python3 notion_to_calendar.py --monat 2026-04
 python3 notion_to_calendar.py --monat 2026-04 --dry-run
 ```
 
-→ Zeigt eine Tabelle mit allen Terminen, die angelegt/aktualisiert würden, ohne irgendetwas zu schreiben.
+→ Zeigt eine Tabelle mit allen Terminen, die angelegt/aktualisiert würden, ohne irgendetwas zu schreiben. Oberhalb der Tabelle erscheint eine Legende zur Farbkodierung.
+
+**Farbkodierung:**
+| Spalte | Farbe  | Bedeutung |
+|--------|--------|-----------|
+| Dauer  | grün   | Tagessumme ≥ 8 h (≥ 480 min) |
+| Dauer  | rot    | Tagessumme > 10 h (> 600 min) |
+| Datum  | orange | Wochensumme > 48 h (> 2880 min) — alle Tage dieser Woche werden markiert |
 
 ### Anderen Kalender wählen
 

+ 5 - 1
TECHNISCHE_DOKUMENTATION.md

@@ -357,7 +357,11 @@ Beispiel:
 | `escape_applescript_string`          | Sonderzeichen-Escaping                                      |
 | `sync_event`                         | State-Maschine: führt CREATE / UPDATE / Fallback aus        |
 | `load_protocol` / `save_protocol`    | JSON-Persistenz                                             |
-| `print_dry_run_table`                | Tabellen-Ausgabe für `--dry-run`                            |
+| `print_dry_run_table`                | Tabellen-Ausgabe für `--dry-run`; gibt Legende aus, färbt „Dauer" (grün/rot) und „Datum" (orange bei Wochenüberschreitung) |
+| `format_duration`                    | Minuten → `Xh Ymin`-String                                  |
+| `duration_color`                     | Gibt ANSI-Farbcode für Dauer-Spalte zurück (`None` / grün / rot) |
+| `_overloaded_week_dates`             | Berechnet die Menge der Datumsangaben in ISO-Wochen, deren Gesamtminuten > 2880 (48 h) |
+| `_print_legend`                      | Gibt die Farbkodierungs-Legende oberhalb der Tabelle aus    |
 | `parse_args` / `main`                | CLI-Einstieg                                                |
 
 ---

+ 1 - 1
config.json

@@ -21,7 +21,7 @@
     "status": "Status"
   },
   "projects": {
-    "EfA-RI-NI": "NII001",
+    "EfA-RI-NI": "NII002",
     "DiPlanung": "ITN001"
   }
 }

+ 84 - 14
notion_to_calendar.py

@@ -532,24 +532,94 @@ def applescript_calendar_missing(stderr: str) -> bool:
 
 # ---------- Ausgabe ----------
 
+ANSI_GREEN = "\033[32m"
+ANSI_RED = "\033[31m"
+ANSI_ORANGE = "\033[38;5;208m"
+ANSI_RESET = "\033[0m"
+
+WEEKLY_OVERTIME_THRESHOLD = 2880  # 48 h in Minuten
+
+
+def format_duration(minutes: int) -> str:
+    hours, mins = divmod(int(minutes), 60)
+    return f"{hours}h {mins}min"
+
+
+def duration_color(minutes: int) -> str | None:
+    if minutes > 600:
+        return ANSI_RED
+    if minutes >= 480:
+        return ANSI_GREEN
+    return None
+
+
+def _overloaded_week_dates(events: list[dict]) -> set[date]:
+    """Dates belonging to ISO weeks whose combined minutes exceed 2880 (48 h)."""
+    weekly: dict[tuple[int, int], int] = defaultdict(int)
+    for ev in events:
+        iso = ev["date"].isocalendar()
+        weekly[(iso[0], iso[1])] += ev["total_minutes"]
+    overloaded = {k for k, v in weekly.items() if v > WEEKLY_OVERTIME_THRESHOLD}
+    return {ev["date"] for ev in events if (ev["date"].isocalendar()[0], ev["date"].isocalendar()[1]) in overloaded}
+
+
+def _print_legend() -> None:
+    print("Legende:")
+    print(f"  Dauer:  {ANSI_GREEN}■{ANSI_RESET} grün   ≥ 8 h (≥ 480 min)   "
+          f"{ANSI_RED}■{ANSI_RESET} rot    > 10 h (> 600 min)")
+    print(f"  Datum:  {ANSI_ORANGE}■{ANSI_RESET} orange = Wochensumme > 48 h (> 2880 min)")
+    print()
+
+
 def print_dry_run_table(events: list[dict], protocol: dict) -> None:
-    rows = [("Zustand", "Datum", "Projekt", "Start", "Ende", "Min")]
+    overloaded_dates = _overloaded_week_dates(events)
+
+    header = ("Zustand", "Datum", "Projekt", "Start", "Ende", "Dauer")
+    data_rows: list[tuple[tuple[str, ...], int, date]] = []
     for ev in events:
         state = determine_state(ev, protocol).upper()
-        rows.append((
-            state,
-            ev["date"].isoformat(),
-            ev["project_name"],
-            ev["start_time"].strftime("%H:%M"),
-            ev["end_time"].strftime("%H:%M"),
-            str(ev["total_minutes"]),
+        data_rows.append((
+            (
+                state,
+                ev["date"].isoformat(),
+                ev["project_name"],
+                ev["start_time"].strftime("%H:%M"),
+                ev["end_time"].strftime("%H:%M"),
+                format_duration(ev["total_minutes"]),
+            ),
+            ev["total_minutes"],
+            ev["date"],
         ))
-    widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
-    line = "  ".join(c.ljust(widths[i]) for i, c in enumerate(rows[0]))
-    print(line)
-    print("-" * len(line))
-    for r in rows[1:]:
-        print("  ".join(c.ljust(widths[i]) for i, c in enumerate(r)))
+
+    all_rows = [header] + [r for r, _, _ in data_rows]
+    widths = [max(len(r[i]) for r in all_rows) for i in range(len(header))]
+
+    _print_legend()
+
+    table_width = sum(widths) + 2 * (len(widths) - 1)
+    header_line = "  ".join(c.ljust(widths[i]) for i, c in enumerate(header))
+    week_sep = "·" * table_width
+    print(header_line)
+    print("-" * table_width)
+    date_idx = 1
+    duration_idx = len(header) - 1
+    prev_iso_week: tuple[int, int] | None = None
+    for row, minutes, ev_date in data_rows:
+        iso_week = (ev_date.isocalendar()[0], ev_date.isocalendar()[1])
+        if prev_iso_week is not None and iso_week != prev_iso_week:
+            print(week_sep)
+        prev_iso_week = iso_week
+        cells = []
+        for i, c in enumerate(row):
+            padded = c.ljust(widths[i])
+            if i == duration_idx:
+                color = duration_color(minutes)
+                if color:
+                    padded = f"{color}{padded}{ANSI_RESET}"
+            elif i == date_idx and ev_date in overloaded_dates:
+                padded = f"{ANSI_ORANGE}{padded}{ANSI_RESET}"
+            cells.append(padded)
+        print("  ".join(cells))
 
 
 # ---------- Sync-Ausführung ----------