fix: Dashboard bug fixes and improvements
Critical bug fixes: - Fix edit undo crash (5-tuple vs 4-tuple handling) - Fix milestone tag lost on undo (store todo.milestone in undo data) - Fix global mode null safety (guards for 8 functions) - Fix ideas priority not saved/loaded - Fix goal deletion for all sections (active/future/non_goals) - Fix goal partial-state undo/redo (widget now emits previous state) - Fix non-goals without checkboxes being dropped on save New features: - Add undo/redo support for goals and ideas (toggle, delete, edit) - Add atomic_write() for non-destructive file saves - Add round-trip tests for parsers Improvements: - Consolidate duplicated milestone todo handlers (delegate to main handlers) - Milestones now save with Active/Completed sections - Global dashboard shows milestone completion ratio 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5c45f7e3f0
commit
69ec7df308
|
|
@ -55,6 +55,7 @@ class Milestone:
|
|||
progress: int = 0 # 0-100
|
||||
deliverables: list[Deliverable] = field(default_factory=list)
|
||||
notes: str = ""
|
||||
description: str = "" # Free-form description text
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
|
|
@ -80,9 +81,16 @@ class Milestone:
|
|||
|
||||
@dataclass
|
||||
class Goal:
|
||||
"""A goal item with priority and status."""
|
||||
"""A goal item with priority and status.
|
||||
|
||||
Goals support three states:
|
||||
- Not achieved: completed=False, partial=False
|
||||
- Partially achieved: completed=False, partial=True
|
||||
- Achieved: completed=True, partial=False
|
||||
"""
|
||||
text: str
|
||||
completed: bool = False
|
||||
partial: bool = False # Partially achieved (orange with half-moon)
|
||||
priority: str = "medium" # "high", "medium", "low"
|
||||
tags: list[str] = field(default_factory=list)
|
||||
project: str | None = None
|
||||
|
|
@ -99,7 +107,7 @@ class GoalList:
|
|||
"""Collection of goals organized by status."""
|
||||
active: list[Goal] = field(default_factory=list)
|
||||
future: list[Goal] = field(default_factory=list)
|
||||
non_goals: list[str] = field(default_factory=list)
|
||||
non_goals: list[Goal] = field(default_factory=list)
|
||||
project: str | None = None
|
||||
updated: str | None = None
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ class Todo:
|
|||
parts.append(f"@{self.project}")
|
||||
for tag in self.tags:
|
||||
parts.append(f"#{tag}")
|
||||
# For completed items, add priority tag so it's preserved when unchecking
|
||||
if self.completed and self.priority in ("high", "medium", "low"):
|
||||
parts.append(f"#{self.priority}")
|
||||
if self.completed_date:
|
||||
parts.append(f"({self.completed_date})")
|
||||
if self.blocker_reason:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,45 @@
|
|||
"""Base parser class with common utilities."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def atomic_write(path: Path, content: str) -> None:
|
||||
"""Write content to file atomically.
|
||||
|
||||
Writes to a temp file first, then renames to target.
|
||||
This prevents data loss if write is interrupted.
|
||||
|
||||
Args:
|
||||
path: Target file path
|
||||
content: Content to write
|
||||
"""
|
||||
# Ensure parent directory exists
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write to temp file in same directory (for atomic rename)
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=path.parent,
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
f.write(content)
|
||||
# Atomic rename
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
# Clean up temp file on failure
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
|
||||
"""Parse YAML frontmatter from markdown content.
|
||||
|
||||
|
|
@ -127,6 +162,29 @@ class BaseParser:
|
|||
return checked, text
|
||||
return False, line.strip()
|
||||
|
||||
@staticmethod
|
||||
def parse_checkbox_triple(line: str) -> tuple[bool, bool, str]:
|
||||
"""Parse a checkbox line with three states.
|
||||
|
||||
Args:
|
||||
line: Line like "- [ ] Task", "- [~] Partial", or "- [x] Done task"
|
||||
|
||||
Returns:
|
||||
Tuple of (is_completed, is_partial, task_text)
|
||||
- [ ] -> (False, False, text)
|
||||
- [~] -> (False, True, text)
|
||||
- [x] -> (True, False, text)
|
||||
"""
|
||||
# Match checkbox pattern with three states
|
||||
match = re.match(r"^-\s*\[([ xX~])\]\s*(.+)$", line.strip())
|
||||
if match:
|
||||
marker = match.group(1).lower()
|
||||
text = match.group(2).strip()
|
||||
completed = marker == "x"
|
||||
partial = marker == "~"
|
||||
return completed, partial, text
|
||||
return False, False, line.strip()
|
||||
|
||||
@staticmethod
|
||||
def extract_milestone_tag(text: str) -> tuple[str | None, str]:
|
||||
"""Extract @M1, @M2, etc. milestone tag from text.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from development_hub.parsers.base import BaseParser
|
||||
from development_hub.parsers.base import BaseParser, atomic_write
|
||||
from development_hub.models.goal import (
|
||||
Goal,
|
||||
GoalList,
|
||||
|
|
@ -63,27 +63,35 @@ class GoalsParser(BaseParser):
|
|||
current_section = "non-goals"
|
||||
continue
|
||||
|
||||
# Handle checkbox items (Active and Future)
|
||||
if line_stripped.startswith("- [") and current_section in ("active", "future"):
|
||||
# Handle checkbox items (Active, Future, and Non-Goals)
|
||||
if line_stripped.startswith("- ["):
|
||||
goal = self._parse_goal_line(line_stripped)
|
||||
if goal:
|
||||
if current_section == "active":
|
||||
goal_list.active.append(goal)
|
||||
else:
|
||||
elif current_section == "future":
|
||||
goal_list.future.append(goal)
|
||||
continue
|
||||
|
||||
# Handle non-goals (simple bullets)
|
||||
if current_section == "non-goals" and line_stripped.startswith("- "):
|
||||
else: # non-goals
|
||||
goal_list.non_goals.append(goal)
|
||||
# Handle plain bullet items in non-goals section (backwards compatibility)
|
||||
elif current_section == "non-goals" and line_stripped.startswith("- "):
|
||||
text = line_stripped[2:].strip()
|
||||
if text:
|
||||
goal_list.non_goals.append(text)
|
||||
# Extract priority from hashtags
|
||||
priority = "medium"
|
||||
tags, text = self.extract_hashtags(text)
|
||||
for tag in tags:
|
||||
if tag in ("high", "medium", "low"):
|
||||
priority = tag
|
||||
break
|
||||
goal = Goal(text=text, completed=False, priority=priority, tags=tags)
|
||||
goal_list.non_goals.append(goal)
|
||||
|
||||
return goal_list
|
||||
|
||||
def _parse_goal_line(self, line: str) -> Goal | None:
|
||||
"""Parse a single goal line."""
|
||||
completed, text = self.parse_checkbox(line)
|
||||
"""Parse a single goal line with three states."""
|
||||
completed, partial, text = self.parse_checkbox_triple(line)
|
||||
|
||||
if not text:
|
||||
return None
|
||||
|
|
@ -103,6 +111,7 @@ class GoalsParser(BaseParser):
|
|||
return Goal(
|
||||
text=text,
|
||||
completed=completed,
|
||||
partial=partial,
|
||||
priority=priority,
|
||||
tags=tags,
|
||||
completed_date=date,
|
||||
|
|
@ -115,14 +124,18 @@ class MilestonesParser(BaseParser):
|
|||
def parse(self) -> list[Milestone]:
|
||||
"""Parse milestones.md file.
|
||||
|
||||
Expected format:
|
||||
Expected format (flat list, no section headers):
|
||||
#### M1: Milestone Name
|
||||
**Target**: January 2026
|
||||
**Status**: In Progress (80%)
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| Item 1 | Done |
|
||||
Description text here...
|
||||
|
||||
---
|
||||
|
||||
#### M2: Another Milestone
|
||||
**Target**: February 2026
|
||||
**Status**: Completed (100%)
|
||||
|
||||
Returns:
|
||||
List of Milestone instances
|
||||
|
|
@ -132,32 +145,50 @@ class MilestonesParser(BaseParser):
|
|||
if not self.body:
|
||||
return milestones
|
||||
|
||||
# Split by milestone headers (#### M#:)
|
||||
milestone_pattern = r"####\s+(M\d+):\s*(.+)"
|
||||
parts = re.split(f"({milestone_pattern})", self.body)
|
||||
milestone_pattern = r"####\s+(M[\d.]+):\s*(.+)"
|
||||
|
||||
i = 0
|
||||
while i < len(parts):
|
||||
part = parts[i]
|
||||
lines = self.body.split("\n")
|
||||
current_milestone_lines = []
|
||||
current_milestone_id = None
|
||||
current_milestone_name = None
|
||||
|
||||
# Check for milestone header match
|
||||
match = re.match(milestone_pattern, part)
|
||||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
|
||||
# Skip section headers (legacy support)
|
||||
if line_stripped.startswith("## "):
|
||||
continue
|
||||
|
||||
# Check for milestone header
|
||||
match = re.match(milestone_pattern, line_stripped)
|
||||
if match:
|
||||
milestone_id = match.group(1)
|
||||
milestone_name = match.group(2).strip()
|
||||
# Save previous milestone if exists
|
||||
if current_milestone_id:
|
||||
milestone = self._parse_milestone_content(
|
||||
current_milestone_id,
|
||||
current_milestone_name,
|
||||
"\n".join(current_milestone_lines),
|
||||
)
|
||||
if milestone:
|
||||
milestones.append(milestone)
|
||||
|
||||
# Get content until next milestone or section
|
||||
content = ""
|
||||
if i + 1 < len(parts):
|
||||
content = parts[i + 1]
|
||||
# Start new milestone
|
||||
current_milestone_id = match.group(1)
|
||||
current_milestone_name = match.group(2).strip()
|
||||
current_milestone_lines = []
|
||||
elif current_milestone_id:
|
||||
# Accumulate lines for current milestone
|
||||
current_milestone_lines.append(line)
|
||||
|
||||
milestone = self._parse_milestone_content(
|
||||
milestone_id, milestone_name, content
|
||||
)
|
||||
if milestone:
|
||||
milestones.append(milestone)
|
||||
|
||||
i += 1
|
||||
# Don't forget the last milestone
|
||||
if current_milestone_id:
|
||||
milestone = self._parse_milestone_content(
|
||||
current_milestone_id,
|
||||
current_milestone_name,
|
||||
"\n".join(current_milestone_lines),
|
||||
)
|
||||
if milestone:
|
||||
milestones.append(milestone)
|
||||
|
||||
return milestones
|
||||
|
||||
|
|
@ -179,6 +210,7 @@ class MilestonesParser(BaseParser):
|
|||
progress = 0
|
||||
deliverables = []
|
||||
notes = ""
|
||||
description_lines = []
|
||||
|
||||
lines = content.split("\n")
|
||||
table_lines = []
|
||||
|
|
@ -187,6 +219,10 @@ class MilestonesParser(BaseParser):
|
|||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
|
||||
# Skip separators
|
||||
if line_stripped == "---":
|
||||
continue
|
||||
|
||||
# Parse **Target**: value
|
||||
target_match = re.match(r"\*\*Target\*\*:\s*(.+)", line_stripped)
|
||||
if target_match:
|
||||
|
|
@ -210,15 +246,16 @@ class MilestonesParser(BaseParser):
|
|||
if line_stripped.startswith("|"):
|
||||
in_table = True
|
||||
table_lines.append(line_stripped)
|
||||
continue
|
||||
elif in_table and not line_stripped.startswith("|"):
|
||||
# Table ended
|
||||
deliverables = self._parse_deliverables_table(table_lines)
|
||||
table_lines = []
|
||||
in_table = False
|
||||
|
||||
# Stop at next section marker
|
||||
if line_stripped.startswith("---") or line_stripped.startswith("## "):
|
||||
break
|
||||
# Collect description lines (non-empty, non-field lines)
|
||||
if line_stripped and not in_table:
|
||||
description_lines.append(line_stripped)
|
||||
|
||||
# Handle any remaining table
|
||||
if table_lines:
|
||||
|
|
@ -232,6 +269,7 @@ class MilestonesParser(BaseParser):
|
|||
progress=progress,
|
||||
deliverables=deliverables,
|
||||
notes=notes,
|
||||
description=" ".join(description_lines),
|
||||
)
|
||||
|
||||
def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]:
|
||||
|
|
@ -323,21 +361,18 @@ class MilestonesParser(BaseParser):
|
|||
# Write active milestones
|
||||
lines.append("## Active")
|
||||
lines.append("")
|
||||
|
||||
for milestone in active:
|
||||
lines.extend(self._format_milestone(milestone))
|
||||
lines.append("")
|
||||
|
||||
# Write completed milestones
|
||||
if completed:
|
||||
lines.append("## Completed")
|
||||
lines.append("## Completed")
|
||||
lines.append("")
|
||||
for milestone in completed:
|
||||
lines.extend(self._format_milestone(milestone))
|
||||
lines.append("")
|
||||
|
||||
for milestone in completed:
|
||||
lines.extend(self._format_milestone(milestone))
|
||||
lines.append("")
|
||||
|
||||
self.path.write_text("\n".join(lines))
|
||||
atomic_write(self.file_path, "\n".join(lines))
|
||||
|
||||
def _format_milestone(self, milestone: Milestone) -> list[str]:
|
||||
"""Format a single milestone as markdown lines.
|
||||
|
|
@ -360,7 +395,7 @@ class MilestonesParser(BaseParser):
|
|||
# Status with progress
|
||||
progress = milestone.calculate_progress()
|
||||
if milestone.status == MilestoneStatus.COMPLETE:
|
||||
lines.append("**Status**: Complete")
|
||||
lines.append(f"**Status**: Completed ({progress}%)")
|
||||
elif milestone.status == MilestoneStatus.IN_PROGRESS:
|
||||
lines.append(f"**Status**: In Progress ({progress}%)")
|
||||
elif milestone.status == MilestoneStatus.PLANNING:
|
||||
|
|
@ -372,10 +407,14 @@ class MilestonesParser(BaseParser):
|
|||
if milestone.notes:
|
||||
lines.append(f"**Notes**: {milestone.notes}")
|
||||
|
||||
lines.append("")
|
||||
# Description (after fields, before table)
|
||||
if milestone.description:
|
||||
lines.append("")
|
||||
lines.append(milestone.description)
|
||||
|
||||
# Deliverables table
|
||||
if milestone.deliverables:
|
||||
lines.append("")
|
||||
lines.append("| Deliverable | Status |")
|
||||
lines.append("|-------------|--------|")
|
||||
|
||||
|
|
@ -422,7 +461,7 @@ class GoalsSaver:
|
|||
lines.append("## Active")
|
||||
lines.append("")
|
||||
for goal in goal_list.active:
|
||||
checkbox = "[x]" if goal.completed else "[ ]"
|
||||
checkbox = self._get_checkbox(goal)
|
||||
priority_tag = f" #{goal.priority}" if goal.priority else ""
|
||||
lines.append(f"- {checkbox} {goal.text}{priority_tag}")
|
||||
lines.append("")
|
||||
|
|
@ -432,16 +471,36 @@ class GoalsSaver:
|
|||
lines.append("## Future")
|
||||
lines.append("")
|
||||
for goal in goal_list.future:
|
||||
checkbox = "[x]" if goal.completed else "[ ]"
|
||||
lines.append(f"- {checkbox} {goal.text}")
|
||||
checkbox = self._get_checkbox(goal)
|
||||
priority_tag = f" #{goal.priority}" if goal.priority else ""
|
||||
lines.append(f"- {checkbox} {goal.text}{priority_tag}")
|
||||
lines.append("")
|
||||
|
||||
# Non-goals
|
||||
# Non-goals (also with checkboxes and priority)
|
||||
if goal_list.non_goals:
|
||||
lines.append("## Non-Goals")
|
||||
lines.append("")
|
||||
for non_goal in goal_list.non_goals:
|
||||
lines.append(f"- {non_goal}")
|
||||
for goal in goal_list.non_goals:
|
||||
checkbox = self._get_checkbox(goal)
|
||||
priority_tag = f" #{goal.priority}" if goal.priority else ""
|
||||
lines.append(f"- {checkbox} {goal.text}{priority_tag}")
|
||||
lines.append("")
|
||||
|
||||
self.path.write_text("\n".join(lines))
|
||||
atomic_write(self.path, "\n".join(lines))
|
||||
|
||||
@staticmethod
|
||||
def _get_checkbox(goal: Goal) -> str:
|
||||
"""Get the checkbox marker for a goal's state.
|
||||
|
||||
Args:
|
||||
goal: Goal to get checkbox for
|
||||
|
||||
Returns:
|
||||
"[x]" for completed, "[~]" for partial, "[ ]" for not achieved
|
||||
"""
|
||||
if goal.completed:
|
||||
return "[x]"
|
||||
elif getattr(goal, 'partial', False):
|
||||
return "[~]"
|
||||
else:
|
||||
return "[ ]"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from development_hub.parsers.base import BaseParser
|
||||
from development_hub.parsers.base import BaseParser, atomic_write
|
||||
from development_hub.models.todo import Todo, TodoList
|
||||
|
||||
|
||||
|
|
@ -134,10 +134,23 @@ class TodosParser(BaseParser):
|
|||
if section == "completed":
|
||||
completed = True
|
||||
|
||||
# For completed items, check if priority is stored in tags
|
||||
# This preserves priority when items are unchecked
|
||||
effective_priority = priority
|
||||
if section == "completed":
|
||||
for tag in tags:
|
||||
if tag in ("high", "medium", "low"):
|
||||
effective_priority = tag
|
||||
tags.remove(tag)
|
||||
break
|
||||
else:
|
||||
# Default to medium if no priority tag found
|
||||
effective_priority = "medium"
|
||||
|
||||
todo = Todo(
|
||||
text=text,
|
||||
completed=completed,
|
||||
priority=priority if not completed else "completed",
|
||||
priority=effective_priority,
|
||||
project=project,
|
||||
milestone=milestone,
|
||||
tags=tags,
|
||||
|
|
@ -262,4 +275,4 @@ class TodosParser(BaseParser):
|
|||
todo_list: TodoList to save
|
||||
"""
|
||||
content = self.write(todo_list)
|
||||
self.file_path.write_text(content)
|
||||
atomic_write(self.file_path, content)
|
||||
|
|
|
|||
|
|
@ -193,6 +193,13 @@ class ProjectListWidget(QWidget):
|
|||
progress_bar.setObjectName("progress_bar")
|
||||
layout.addWidget(progress_bar)
|
||||
|
||||
# OK button (hidden until complete)
|
||||
ok_button = QPushButton("OK")
|
||||
ok_button.setObjectName("ok_button")
|
||||
ok_button.clicked.connect(dialog.accept)
|
||||
ok_button.hide()
|
||||
layout.addWidget(ok_button)
|
||||
|
||||
return dialog
|
||||
|
||||
def _deploy_docs(self, project: Project):
|
||||
|
|
@ -350,6 +357,11 @@ class ProjectListWidget(QWidget):
|
|||
"Deploy Complete" if success else "Deploy Failed"
|
||||
)
|
||||
|
||||
# Show OK button
|
||||
ok_button = self._progress_dialog.findChild(QPushButton, "ok_button")
|
||||
if ok_button:
|
||||
ok_button.show()
|
||||
|
||||
self._deploy_thread = None
|
||||
|
||||
def _rebuild_main_docs(self):
|
||||
|
|
@ -394,6 +406,11 @@ class ProjectListWidget(QWidget):
|
|||
"Rebuild Complete" if success else "Rebuild Failed"
|
||||
)
|
||||
|
||||
# Show OK button
|
||||
ok_button = self._progress_dialog.findChild(QPushButton, "ok_button")
|
||||
if ok_button:
|
||||
ok_button.show()
|
||||
|
||||
self._rebuild_thread = None
|
||||
|
||||
def _update_docs(self, project: Project):
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,284 +1,36 @@
|
|||
"""Global dashboard view showing cross-project status."""
|
||||
"""Global dashboard view - wrapper around ProjectDashboard in global mode."""
|
||||
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout
|
||||
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QFrame,
|
||||
QPushButton,
|
||||
)
|
||||
|
||||
from development_hub.services.health_checker import HealthChecker
|
||||
from development_hub.parsers.goals_parser import MilestonesParser
|
||||
from development_hub.parsers.progress_parser import ProgressLogManager
|
||||
from development_hub.widgets.progress_bar import MilestoneProgressBar
|
||||
from development_hub.widgets.stat_card import StatCardRow
|
||||
from development_hub.widgets.health_card import HealthCardCompact
|
||||
from development_hub.views.dashboard import ProjectDashboard
|
||||
|
||||
|
||||
class GlobalDashboard(QWidget):
|
||||
"""Global dashboard showing status across all projects.
|
||||
|
||||
Displays:
|
||||
- Current milestone progress (global)
|
||||
- Project health summary
|
||||
- Projects needing attention
|
||||
- Today's progress
|
||||
This is a thin wrapper around ProjectDashboard in global mode.
|
||||
"""
|
||||
|
||||
project_selected = pyqtSignal(str) # Emits project_key
|
||||
standup_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
"""Initialize global dashboard.
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
"""
|
||||
"""Initialize global dashboard."""
|
||||
super().__init__(parent)
|
||||
self._docs_root = Path.home() / "PycharmProjects" / "project-docs" / "docs"
|
||||
self._setup_ui()
|
||||
self._load_data()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
# Main scroll area
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameStyle(QFrame.Shape.NoFrame)
|
||||
scroll.setStyleSheet("QScrollArea { background-color: #1e1e1e; border: none; }")
|
||||
# Create ProjectDashboard in global mode
|
||||
self._dashboard = ProjectDashboard(project=None, parent=self)
|
||||
|
||||
# Content widget
|
||||
content = QWidget()
|
||||
content.setStyleSheet("background-color: #1e1e1e;")
|
||||
layout = QVBoxLayout(content)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
layout.setSpacing(20)
|
||||
# Forward signals
|
||||
self._dashboard.project_selected.connect(self.project_selected.emit)
|
||||
self._dashboard.standup_requested.connect(self.standup_requested.emit)
|
||||
|
||||
# Header
|
||||
header_layout = QHBoxLayout()
|
||||
|
||||
title = QLabel("Development Hub")
|
||||
title.setStyleSheet("font-size: 24px; font-weight: bold; color: #e0e0e0;")
|
||||
header_layout.addWidget(title)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
# Daily standup button
|
||||
standup_btn = QPushButton("Daily Standup")
|
||||
standup_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #3d6a99;
|
||||
color: #e0e0e0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #4a7ab0;
|
||||
}
|
||||
""")
|
||||
standup_btn.clicked.connect(self.standup_requested.emit)
|
||||
header_layout.addWidget(standup_btn)
|
||||
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# Milestone progress
|
||||
milestone_section = QLabel("CURRENT MILESTONE")
|
||||
milestone_section.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888;")
|
||||
layout.addWidget(milestone_section)
|
||||
|
||||
self.milestone_progress = MilestoneProgressBar()
|
||||
layout.addWidget(self.milestone_progress)
|
||||
|
||||
# Stats row
|
||||
self.stats_row = StatCardRow()
|
||||
self.stats_row.add_stat("healthy", 0, "Healthy", "#4caf50")
|
||||
self.stats_row.add_stat("warning", 0, "Warning", "#ff9800")
|
||||
self.stats_row.add_stat("total_todos", 0, "Total TODOs", "#e0e0e0")
|
||||
layout.addWidget(self.stats_row)
|
||||
|
||||
# Action buttons
|
||||
actions_layout = QHBoxLayout()
|
||||
actions_layout.setSpacing(8)
|
||||
|
||||
view_todos_btn = QPushButton("View Global TODOs")
|
||||
view_todos_btn.setStyleSheet(self._button_style())
|
||||
view_todos_btn.clicked.connect(self._open_todos)
|
||||
actions_layout.addWidget(view_todos_btn)
|
||||
|
||||
view_milestones_btn = QPushButton("View Milestones")
|
||||
view_milestones_btn.setStyleSheet(self._button_style())
|
||||
view_milestones_btn.clicked.connect(self._open_milestones)
|
||||
actions_layout.addWidget(view_milestones_btn)
|
||||
|
||||
actions_layout.addStretch()
|
||||
layout.addLayout(actions_layout)
|
||||
|
||||
# Project health section
|
||||
health_label = QLabel("PROJECT HEALTH")
|
||||
health_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
|
||||
layout.addWidget(health_label)
|
||||
|
||||
self.health_container = QVBoxLayout()
|
||||
self.health_container.setSpacing(4)
|
||||
layout.addLayout(self.health_container)
|
||||
|
||||
# Needs attention section
|
||||
attention_label = QLabel("NEEDS ATTENTION")
|
||||
attention_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
|
||||
layout.addWidget(attention_label)
|
||||
|
||||
self.attention_list = QLabel()
|
||||
self.attention_list.setStyleSheet("color: #ff9800; line-height: 1.6;")
|
||||
self.attention_list.setWordWrap(True)
|
||||
layout.addWidget(self.attention_list)
|
||||
|
||||
# Today's progress section
|
||||
progress_label = QLabel("TODAY'S PROGRESS")
|
||||
progress_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
|
||||
layout.addWidget(progress_label)
|
||||
|
||||
self.progress_list = QLabel()
|
||||
self.progress_list.setStyleSheet("color: #b0b0b0; line-height: 1.6;")
|
||||
self.progress_list.setWordWrap(True)
|
||||
layout.addWidget(self.progress_list)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
scroll.setWidget(content)
|
||||
|
||||
# Main layout
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(scroll)
|
||||
|
||||
def _load_data(self):
|
||||
"""Load data and update display."""
|
||||
# Get ecosystem health
|
||||
checker = HealthChecker()
|
||||
ecosystem = checker.check_all_projects()
|
||||
|
||||
# Update stats
|
||||
self.stats_row.update_stat("healthy", ecosystem.healthy_count)
|
||||
self.stats_row.update_stat("warning", ecosystem.warning_count + ecosystem.critical_count)
|
||||
self.stats_row.update_stat("total_todos", ecosystem.total_active_todos)
|
||||
|
||||
# Load milestone
|
||||
milestones_path = self._docs_root / "goals" / "milestones.md"
|
||||
if milestones_path.exists():
|
||||
parser = MilestonesParser(milestones_path)
|
||||
current = parser.get_current_milestone()
|
||||
if current:
|
||||
self.milestone_progress.set_milestone(
|
||||
f"{current.id}: {current.name}",
|
||||
current.progress,
|
||||
current.target,
|
||||
)
|
||||
else:
|
||||
self.milestone_progress.clear()
|
||||
else:
|
||||
self.milestone_progress.clear()
|
||||
|
||||
# Clear and rebuild health cards
|
||||
while self.health_container.count():
|
||||
item = self.health_container.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
for health in ecosystem.projects:
|
||||
card = HealthCardCompact()
|
||||
card.set_project(
|
||||
health.project_key,
|
||||
health.project_name,
|
||||
health.status_icon,
|
||||
health.git_info.time_since_commit,
|
||||
health.active_todos,
|
||||
)
|
||||
card.clicked.connect(self._on_project_clicked)
|
||||
self.health_container.addWidget(card)
|
||||
|
||||
# Needs attention
|
||||
needing = ecosystem.get_needing_attention()
|
||||
if needing:
|
||||
attention_html = "<br>".join(
|
||||
f"⚠ {h.project_name}: {', '.join(h.attention_reasons[:2])}"
|
||||
for h in needing[:5]
|
||||
)
|
||||
self.attention_list.setText(attention_html)
|
||||
else:
|
||||
self.attention_list.setText("All projects healthy!")
|
||||
self.attention_list.setStyleSheet("color: #4caf50; line-height: 1.6;")
|
||||
|
||||
# Today's progress
|
||||
progress_dir = self._docs_root / "progress"
|
||||
if progress_dir.exists():
|
||||
manager = ProgressLogManager(progress_dir)
|
||||
today = manager.get_today()
|
||||
if today:
|
||||
items = []
|
||||
for task in today.completed[:5]:
|
||||
items.append(f"☑ {task}")
|
||||
for task in today.in_progress[:3]:
|
||||
items.append(f"☐ {task} (in progress)")
|
||||
if items:
|
||||
self.progress_list.setText("<br>".join(items))
|
||||
else:
|
||||
self.progress_list.setText("No progress logged yet today")
|
||||
else:
|
||||
self.progress_list.setText("No progress log for today")
|
||||
else:
|
||||
self.progress_list.setText("Progress directory not found")
|
||||
|
||||
def _on_project_clicked(self, project_key: str):
|
||||
"""Handle project card click."""
|
||||
self.project_selected.emit(project_key)
|
||||
# Layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(self._dashboard)
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the dashboard data."""
|
||||
self._load_data()
|
||||
|
||||
def _button_style(self) -> str:
|
||||
"""Get button stylesheet."""
|
||||
return """
|
||||
QPushButton {
|
||||
background-color: #3d3d3d;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #4d4d4d;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #4d4d4d;
|
||||
}
|
||||
"""
|
||||
|
||||
def _open_file_in_editor(self, path: Path):
|
||||
"""Open a file in the default editor."""
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
editors = ["pycharm", "code", "subl", "gedit", "xdg-open"]
|
||||
for editor in editors:
|
||||
if shutil.which(editor):
|
||||
subprocess.Popen([editor, str(path)])
|
||||
return
|
||||
|
||||
def _open_todos(self):
|
||||
"""Open the global todos.md file."""
|
||||
todos_path = self._docs_root / "todos.md"
|
||||
self._open_file_in_editor(todos_path)
|
||||
|
||||
def _open_milestones(self):
|
||||
"""Open the milestones.md file."""
|
||||
milestones_path = self._docs_root / "goals" / "milestones.md"
|
||||
self._open_file_in_editor(milestones_path)
|
||||
self._dashboard._load_data()
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ class TodoItemWidget(QWidget):
|
|||
toggled = pyqtSignal(object, bool) # Emits (todo, completed)
|
||||
deleted = pyqtSignal(object) # Emits todo
|
||||
start_discussion = pyqtSignal(object) # Emits todo
|
||||
edited = pyqtSignal(object, str, str) # Emits (todo, old_text, new_text)
|
||||
|
||||
def __init__(self, todo, parent: QWidget | None = None, show_priority: bool = False):
|
||||
"""Initialize todo item widget.
|
||||
|
|
@ -194,6 +195,8 @@ class TodoItemWidget(QWidget):
|
|||
super().__init__(parent)
|
||||
self.todo = todo
|
||||
self._show_priority = show_priority
|
||||
self._editing = False
|
||||
self._edit_widget = None
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
|
|
@ -210,9 +213,11 @@ class TodoItemWidget(QWidget):
|
|||
self.checkbox.setFixedWidth(20)
|
||||
layout.addWidget(self.checkbox)
|
||||
|
||||
# Text
|
||||
# Text (double-click to edit)
|
||||
self.text_label = QLabel(self.todo.text)
|
||||
self.text_label.setWordWrap(True)
|
||||
self.text_label.setCursor(Qt.CursorShape.IBeamCursor)
|
||||
self.text_label.mouseDoubleClickEvent = self._start_editing
|
||||
self._update_text_style()
|
||||
layout.addWidget(self.text_label, 1)
|
||||
|
||||
|
|
@ -294,6 +299,91 @@ class TodoItemWidget(QWidget):
|
|||
self.delete_btn.hide()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def _start_editing(self, event):
|
||||
"""Start inline editing of the todo text."""
|
||||
if self._editing:
|
||||
return
|
||||
|
||||
self._editing = True
|
||||
self._original_text = self.todo.text
|
||||
|
||||
# Create edit widget in place of label
|
||||
self._edit_widget = QLineEdit(self.todo.text)
|
||||
self._edit_widget.setStyleSheet("""
|
||||
QLineEdit {
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid #4a9eff;
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
""")
|
||||
|
||||
# Connect signals - Enter to save, event filter handles Escape and focus out
|
||||
self._edit_widget.returnPressed.connect(self._finish_editing)
|
||||
self._edit_widget.installEventFilter(self)
|
||||
|
||||
# Replace label with edit widget
|
||||
layout = self.layout()
|
||||
label_index = layout.indexOf(self.text_label)
|
||||
self.text_label.hide()
|
||||
layout.insertWidget(label_index, self._edit_widget)
|
||||
|
||||
# Focus and select all
|
||||
self._edit_widget.setFocus()
|
||||
self._edit_widget.selectAll()
|
||||
|
||||
def _finish_editing(self):
|
||||
"""Finish editing and save changes."""
|
||||
if not self._editing or not self._edit_widget:
|
||||
return
|
||||
|
||||
new_text = self._edit_widget.text().strip()
|
||||
self._editing = False
|
||||
|
||||
# Remove edit widget
|
||||
self._edit_widget.hide()
|
||||
self._edit_widget.deleteLater()
|
||||
self._edit_widget = None
|
||||
|
||||
# Show label again
|
||||
self.text_label.show()
|
||||
|
||||
# Only emit if text changed and not empty
|
||||
if new_text and new_text != self._original_text:
|
||||
self.text_label.setText(new_text)
|
||||
self.edited.emit(self.todo, self._original_text, new_text)
|
||||
|
||||
def _cancel_editing(self):
|
||||
"""Cancel editing and restore original text."""
|
||||
if not self._editing or not self._edit_widget:
|
||||
return
|
||||
|
||||
self._editing = False
|
||||
|
||||
# Remove edit widget
|
||||
self._edit_widget.hide()
|
||||
self._edit_widget.deleteLater()
|
||||
self._edit_widget = None
|
||||
|
||||
# Show label again (with original text)
|
||||
self.text_label.show()
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
"""Handle Escape key to cancel editing, focus out to save."""
|
||||
from PyQt6.QtCore import QEvent
|
||||
|
||||
if obj == self._edit_widget:
|
||||
if event.type() == QEvent.Type.KeyPress:
|
||||
if event.key() == Qt.Key.Key_Escape:
|
||||
self._cancel_editing()
|
||||
return True
|
||||
elif event.type() == QEvent.Type.FocusOut:
|
||||
# Save on focus loss
|
||||
self._finish_editing()
|
||||
return False
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
"""Show context menu on right-click."""
|
||||
menu = QMenu(self)
|
||||
|
|
@ -326,18 +416,23 @@ class TodoItemWidget(QWidget):
|
|||
class GoalItemWidget(QWidget):
|
||||
"""Widget for displaying a single goal item with checkbox."""
|
||||
|
||||
toggled = pyqtSignal(object, bool) # Emits (goal, completed)
|
||||
toggled = pyqtSignal(object, bool, bool, bool) # Emits (goal, new_completed, was_completed, was_partial)
|
||||
deleted = pyqtSignal(object) # Emits goal
|
||||
edited = pyqtSignal(object, str, str) # Emits (goal, old_text, new_text)
|
||||
|
||||
def __init__(self, goal, parent: QWidget | None = None):
|
||||
def __init__(self, goal, parent: QWidget | None = None, is_non_goal: bool = False):
|
||||
"""Initialize goal item widget.
|
||||
|
||||
Args:
|
||||
goal: Goal model instance
|
||||
parent: Parent widget
|
||||
is_non_goal: If True, show green when checked instead of strikethrough
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.goal = goal
|
||||
self._is_non_goal = is_non_goal
|
||||
self._editing = False
|
||||
self._edit_widget = None
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
|
|
@ -354,9 +449,11 @@ class GoalItemWidget(QWidget):
|
|||
self.checkbox.setFixedWidth(20)
|
||||
layout.addWidget(self.checkbox)
|
||||
|
||||
# Text
|
||||
# Text (double-click to edit)
|
||||
self.text_label = QLabel(self.goal.text)
|
||||
self.text_label.setWordWrap(True)
|
||||
self.text_label.setCursor(Qt.CursorShape.IBeamCursor)
|
||||
self.text_label.mouseDoubleClickEvent = self._start_editing
|
||||
self._update_text_style()
|
||||
layout.addWidget(self.text_label, 1)
|
||||
|
||||
|
|
@ -394,29 +491,56 @@ class GoalItemWidget(QWidget):
|
|||
layout.addWidget(self.delete_btn)
|
||||
|
||||
def _update_checkbox(self):
|
||||
"""Update checkbox display."""
|
||||
"""Update checkbox display for three states."""
|
||||
if self.goal.completed:
|
||||
self.checkbox.setText("☑")
|
||||
self.checkbox.setStyleSheet("color: #4caf50; font-size: 16px;")
|
||||
elif getattr(self.goal, 'partial', False):
|
||||
self.checkbox.setText("◐")
|
||||
self.checkbox.setStyleSheet("color: #ff9800; font-size: 16px;")
|
||||
else:
|
||||
self.checkbox.setText("☐")
|
||||
self.checkbox.setStyleSheet("color: #888888; font-size: 16px;")
|
||||
|
||||
def _update_text_style(self):
|
||||
"""Update text style based on completion."""
|
||||
"""Update text style based on goal state.
|
||||
|
||||
Goals use green when achieved (no strikethrough) since they represent
|
||||
ongoing commitments that require continued focus even after achievement.
|
||||
"""
|
||||
if self.goal.completed:
|
||||
self.text_label.setStyleSheet(
|
||||
"color: #666666; text-decoration: line-through; font-size: 13px;"
|
||||
)
|
||||
# Achieved: green text, no strikethrough
|
||||
self.text_label.setStyleSheet("color: #4caf50; font-size: 13px;")
|
||||
elif getattr(self.goal, 'partial', False):
|
||||
# Partially achieved: orange text
|
||||
self.text_label.setStyleSheet("color: #ff9800; font-size: 13px;")
|
||||
else:
|
||||
# Not achieved: white text
|
||||
self.text_label.setStyleSheet("color: #e0e0e0; font-size: 13px;")
|
||||
|
||||
def _on_checkbox_clicked(self, event):
|
||||
"""Handle checkbox click."""
|
||||
self.goal.completed = not self.goal.completed
|
||||
"""Handle checkbox click - cycle through states.
|
||||
|
||||
Cycle: Not achieved -> Partial -> Achieved -> Not achieved
|
||||
"""
|
||||
# Capture previous state before modifying
|
||||
was_completed = self.goal.completed
|
||||
was_partial = getattr(self.goal, 'partial', False)
|
||||
|
||||
if self.goal.completed:
|
||||
# Achieved -> Not achieved
|
||||
self.goal.completed = False
|
||||
self.goal.partial = False
|
||||
elif getattr(self.goal, 'partial', False):
|
||||
# Partial -> Achieved
|
||||
self.goal.completed = True
|
||||
self.goal.partial = False
|
||||
else:
|
||||
# Not achieved -> Partial
|
||||
self.goal.partial = True
|
||||
self._update_checkbox()
|
||||
self._update_text_style()
|
||||
self.toggled.emit(self.goal, self.goal.completed)
|
||||
self.toggled.emit(self.goal, self.goal.completed, was_completed, was_partial)
|
||||
|
||||
def enterEvent(self, event):
|
||||
"""Show delete button on hover."""
|
||||
|
|
@ -428,6 +552,91 @@ class GoalItemWidget(QWidget):
|
|||
self.delete_btn.hide()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def _start_editing(self, event):
|
||||
"""Start inline editing of the goal text."""
|
||||
if self._editing:
|
||||
return
|
||||
|
||||
self._editing = True
|
||||
self._original_text = self.goal.text
|
||||
|
||||
# Create edit widget in place of label
|
||||
self._edit_widget = QLineEdit(self.goal.text)
|
||||
self._edit_widget.setStyleSheet("""
|
||||
QLineEdit {
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid #4a9eff;
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
""")
|
||||
|
||||
# Connect signals - Enter to save, event filter handles Escape and focus out
|
||||
self._edit_widget.returnPressed.connect(self._finish_editing)
|
||||
self._edit_widget.installEventFilter(self)
|
||||
|
||||
# Replace label with edit widget
|
||||
layout = self.layout()
|
||||
label_index = layout.indexOf(self.text_label)
|
||||
self.text_label.hide()
|
||||
layout.insertWidget(label_index, self._edit_widget)
|
||||
|
||||
# Focus and select all
|
||||
self._edit_widget.setFocus()
|
||||
self._edit_widget.selectAll()
|
||||
|
||||
def _finish_editing(self):
|
||||
"""Finish editing and save changes."""
|
||||
if not self._editing or not self._edit_widget:
|
||||
return
|
||||
|
||||
new_text = self._edit_widget.text().strip()
|
||||
self._editing = False
|
||||
|
||||
# Remove edit widget
|
||||
self._edit_widget.hide()
|
||||
self._edit_widget.deleteLater()
|
||||
self._edit_widget = None
|
||||
|
||||
# Show label again
|
||||
self.text_label.show()
|
||||
|
||||
# Only emit if text changed and not empty
|
||||
if new_text and new_text != self._original_text:
|
||||
self.text_label.setText(new_text)
|
||||
self.edited.emit(self.goal, self._original_text, new_text)
|
||||
|
||||
def _cancel_editing(self):
|
||||
"""Cancel editing and restore original text."""
|
||||
if not self._editing or not self._edit_widget:
|
||||
return
|
||||
|
||||
self._editing = False
|
||||
|
||||
# Remove edit widget
|
||||
self._edit_widget.hide()
|
||||
self._edit_widget.deleteLater()
|
||||
self._edit_widget = None
|
||||
|
||||
# Show label again (with original text)
|
||||
self.text_label.show()
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
"""Handle Escape key to cancel editing, focus out to save."""
|
||||
from PyQt6.QtCore import QEvent
|
||||
|
||||
if obj == self._edit_widget:
|
||||
if event.type() == QEvent.Type.KeyPress:
|
||||
if event.key() == Qt.Key.Key_Escape:
|
||||
self._cancel_editing()
|
||||
return True
|
||||
elif event.type() == QEvent.Type.FocusOut:
|
||||
# Save on focus loss
|
||||
self._finish_editing()
|
||||
return False
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
|
||||
class DeliverableItemWidget(QWidget):
|
||||
"""Widget for displaying a single deliverable item with checkbox."""
|
||||
|
|
@ -551,6 +760,7 @@ class MilestoneWidget(QFrame):
|
|||
todo_deleted = pyqtSignal(object) # (todo) - for linked todos mode
|
||||
todo_added = pyqtSignal(str, str, str) # (text, priority, milestone_id) - for adding new todos
|
||||
todo_start_discussion = pyqtSignal(object) # (todo) - for starting discussion from todo
|
||||
todo_edited = pyqtSignal(object, str, str) # (todo, old_text, new_text) - for inline editing
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -607,6 +817,10 @@ class MilestoneWidget(QFrame):
|
|||
self.title_label.setStyleSheet("font-weight: bold; color: #e0e0e0; font-size: 13px;")
|
||||
header_layout.addWidget(self.title_label)
|
||||
|
||||
# Set tooltip with description if available
|
||||
if self.milestone.description:
|
||||
self.header.setToolTip(self.milestone.description)
|
||||
|
||||
# Progress bar right after label
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setMinimum(0)
|
||||
|
|
@ -734,12 +948,20 @@ class MilestoneWidget(QFrame):
|
|||
self.arrow_label.setStyleSheet("color: #888888; font-size: 10px;")
|
||||
|
||||
def _update_status_icon(self):
|
||||
"""Update status icon based on milestone status."""
|
||||
"""Update status icon based on milestone status or linked todos progress."""
|
||||
from development_hub.models.goal import MilestoneStatus
|
||||
if self.milestone.status == MilestoneStatus.COMPLETE:
|
||||
|
||||
# Check if complete - either from milestone status or all linked todos done
|
||||
is_complete = self.milestone.status == MilestoneStatus.COMPLETE
|
||||
if self._todos:
|
||||
total = len(self._todos)
|
||||
done = sum(1 for t in self._todos if t.completed)
|
||||
is_complete = is_complete or (total > 0 and done == total)
|
||||
|
||||
if is_complete:
|
||||
self.status_icon.setText("✓")
|
||||
self.status_icon.setStyleSheet("color: #4caf50; font-size: 14px;")
|
||||
elif self.milestone.status == MilestoneStatus.IN_PROGRESS:
|
||||
elif self.milestone.status == MilestoneStatus.IN_PROGRESS or (self._todos and any(t.completed for t in self._todos)):
|
||||
self.status_icon.setText("●")
|
||||
self.status_icon.setStyleSheet("color: #4a9eff; font-size: 14px;")
|
||||
else:
|
||||
|
|
@ -798,6 +1020,7 @@ class MilestoneWidget(QFrame):
|
|||
widget.toggled.connect(self._on_todo_toggled_internal)
|
||||
widget.deleted.connect(self._on_todo_deleted_internal)
|
||||
widget.start_discussion.connect(self.todo_start_discussion.emit)
|
||||
widget.edited.connect(self.todo_edited.emit)
|
||||
self.deliverables_layout.addWidget(widget)
|
||||
else:
|
||||
# Legacy: Add deliverables
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Project health card widget."""
|
||||
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QLabel
|
||||
from PyQt6.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QLabel, QProgressBar
|
||||
|
||||
|
||||
class HealthCard(QFrame):
|
||||
|
|
@ -158,7 +158,35 @@ class HealthCardCompact(QFrame):
|
|||
# Name
|
||||
self.name_label = QLabel()
|
||||
self.name_label.setStyleSheet("color: #e0e0e0;")
|
||||
layout.addWidget(self.name_label, stretch=1)
|
||||
self.name_label.setMinimumWidth(120)
|
||||
layout.addWidget(self.name_label)
|
||||
|
||||
# Milestone progress bar
|
||||
self.milestone_progress = QProgressBar()
|
||||
self.milestone_progress.setMaximumHeight(8)
|
||||
self.milestone_progress.setMinimumWidth(80)
|
||||
self.milestone_progress.setMaximumWidth(120)
|
||||
self.milestone_progress.setTextVisible(False)
|
||||
self.milestone_progress.setStyleSheet("""
|
||||
QProgressBar {
|
||||
background-color: #2d2d2d;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #4a90d9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(self.milestone_progress)
|
||||
|
||||
# Progress percentage label
|
||||
self.progress_label = QLabel()
|
||||
self.progress_label.setStyleSheet("color: #888888; font-size: 10px;")
|
||||
self.progress_label.setMinimumWidth(30)
|
||||
layout.addWidget(self.progress_label)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Time
|
||||
self.time_label = QLabel()
|
||||
|
|
@ -177,6 +205,7 @@ class HealthCardCompact(QFrame):
|
|||
status_icon: str,
|
||||
time_since_commit: str,
|
||||
active_todos: int,
|
||||
milestone_progress: int = 0,
|
||||
):
|
||||
"""Set project data."""
|
||||
self._project_key = project_key
|
||||
|
|
@ -185,6 +214,31 @@ class HealthCardCompact(QFrame):
|
|||
self.time_label.setText(time_since_commit)
|
||||
self.todos_label.setText(f"📊 {active_todos}")
|
||||
|
||||
# Set milestone progress
|
||||
self.milestone_progress.setValue(milestone_progress)
|
||||
if milestone_progress > 0:
|
||||
self.progress_label.setText(f"{milestone_progress}%")
|
||||
# Color based on progress
|
||||
if milestone_progress >= 80:
|
||||
color = "#4caf50" # Green
|
||||
elif milestone_progress >= 50:
|
||||
color = "#4a90d9" # Blue
|
||||
else:
|
||||
color = "#ff9800" # Orange
|
||||
self.milestone_progress.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background-color: #2d2d2d;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background-color: {color};
|
||||
border-radius: 4px;
|
||||
}}
|
||||
""")
|
||||
else:
|
||||
self.progress_label.setText("")
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
"""Handle mouse press."""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
|
|
|
|||
|
|
@ -307,7 +307,17 @@ class PaneWidget(QFrame):
|
|||
"title": title,
|
||||
"cwd": str(widget.cwd),
|
||||
})
|
||||
# Skip welcome tab and other non-terminal widgets
|
||||
elif isinstance(widget, ProjectDashboard):
|
||||
tabs.append({
|
||||
"type": "dashboard",
|
||||
"project_key": widget.project.key,
|
||||
"state": widget.get_state(),
|
||||
})
|
||||
elif isinstance(widget, GlobalDashboard):
|
||||
tabs.append({
|
||||
"type": "global_dashboard",
|
||||
})
|
||||
# Skip welcome tab and other non-saveable widgets
|
||||
|
||||
return {
|
||||
"tabs": tabs,
|
||||
|
|
@ -316,15 +326,34 @@ class PaneWidget(QFrame):
|
|||
|
||||
def restore_tabs(self, state: dict):
|
||||
"""Restore tabs from session state."""
|
||||
from development_hub.project_discovery import discover_projects
|
||||
|
||||
tabs = state.get("tabs", [])
|
||||
current_tab = state.get("current_tab", 0)
|
||||
|
||||
# Cache discovered projects for dashboard restoration
|
||||
projects_by_key = {p.key: p for p in discover_projects()}
|
||||
|
||||
for tab_info in tabs:
|
||||
if tab_info.get("type") == "terminal":
|
||||
tab_type = tab_info.get("type")
|
||||
|
||||
if tab_type == "terminal":
|
||||
cwd = Path(tab_info.get("cwd", str(Path.home())))
|
||||
title = tab_info.get("title", "Terminal")
|
||||
self.add_terminal(cwd, title)
|
||||
|
||||
elif tab_type == "dashboard":
|
||||
project_key = tab_info.get("project_key")
|
||||
if project_key and project_key in projects_by_key:
|
||||
project = projects_by_key[project_key]
|
||||
dashboard = self.add_dashboard(project)
|
||||
# Restore dashboard state
|
||||
if dashboard and "state" in tab_info:
|
||||
dashboard.restore_state(tab_info["state"])
|
||||
|
||||
elif tab_type == "global_dashboard":
|
||||
self.add_global_dashboard()
|
||||
|
||||
# Restore current tab selection
|
||||
if 0 <= current_tab < self.tab_widget.count():
|
||||
self.tab_widget.setCurrentIndex(current_tab)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,395 @@
|
|||
"""Round-trip tests for parsers.
|
||||
|
||||
These tests verify that parse -> save -> parse produces identical data.
|
||||
This ensures no data loss during save operations.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from development_hub.parsers.todos_parser import TodosParser
|
||||
from development_hub.parsers.goals_parser import GoalsParser, MilestonesParser, GoalsSaver
|
||||
from development_hub.models.todo import Todo, TodoList
|
||||
from development_hub.models.goal import Goal, GoalList, Milestone, Deliverable, MilestoneStatus, DeliverableStatus
|
||||
|
||||
|
||||
class TestTodosRoundtrip:
|
||||
"""Test TodosParser round-trip preservation."""
|
||||
|
||||
def test_simple_todos_roundtrip(self, tmp_path):
|
||||
"""Parse -> save -> parse should produce identical data."""
|
||||
todos_file = tmp_path / "todos.md"
|
||||
original_content = """---
|
||||
type: todos
|
||||
project: test-project
|
||||
updated: 2026-01-08
|
||||
---
|
||||
|
||||
# TODOs
|
||||
|
||||
## Active Tasks
|
||||
|
||||
### High Priority
|
||||
|
||||
- [ ] Important task #high
|
||||
- [ ] Another important task #high @M1
|
||||
|
||||
### Medium Priority
|
||||
|
||||
- [ ] Medium task #medium
|
||||
- [x] Completed medium task #medium (2026-01-07)
|
||||
|
||||
### Low Priority
|
||||
|
||||
- [ ] Low priority task #low
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] Done task #high (2026-01-06)
|
||||
- [x] Another done #medium (2026-01-05)
|
||||
"""
|
||||
todos_file.write_text(original_content)
|
||||
|
||||
# First parse
|
||||
parser1 = TodosParser(todos_file)
|
||||
todo_list1 = parser1.parse()
|
||||
|
||||
# Save
|
||||
parser1.save(todo_list1)
|
||||
|
||||
# Second parse
|
||||
parser2 = TodosParser(todos_file)
|
||||
todo_list2 = parser2.parse()
|
||||
|
||||
# Compare
|
||||
assert len(todo_list1.all_todos) == len(todo_list2.all_todos)
|
||||
assert len(todo_list1.completed) == len(todo_list2.completed)
|
||||
|
||||
# Compare individual todos
|
||||
for t1, t2 in zip(todo_list1.all_todos, todo_list2.all_todos):
|
||||
assert t1.text == t2.text
|
||||
assert t1.priority == t2.priority
|
||||
assert t1.completed == t2.completed
|
||||
assert t1.milestone == t2.milestone
|
||||
|
||||
def test_todos_with_milestones_roundtrip(self, tmp_path):
|
||||
"""Milestone tags should be preserved."""
|
||||
todos_file = tmp_path / "todos.md"
|
||||
original_content = """---
|
||||
type: todos
|
||||
project: test
|
||||
---
|
||||
|
||||
# TODOs
|
||||
|
||||
## Active Tasks
|
||||
|
||||
### High Priority
|
||||
|
||||
- [ ] Task with milestone @M1 #high
|
||||
- [ ] Task with M2 @M2 #high
|
||||
|
||||
### Medium Priority
|
||||
|
||||
### Low Priority
|
||||
|
||||
## Completed
|
||||
"""
|
||||
todos_file.write_text(original_content)
|
||||
|
||||
parser1 = TodosParser(todos_file)
|
||||
todo_list1 = parser1.parse()
|
||||
|
||||
# Verify milestones parsed
|
||||
high_todos = [t for t in todo_list1.all_todos if t.priority == "high"]
|
||||
assert any(t.milestone == "M1" for t in high_todos)
|
||||
assert any(t.milestone == "M2" for t in high_todos)
|
||||
|
||||
# Save and reparse
|
||||
parser1.save(todo_list1)
|
||||
parser2 = TodosParser(todos_file)
|
||||
todo_list2 = parser2.parse()
|
||||
|
||||
# Verify milestones preserved
|
||||
high_todos2 = [t for t in todo_list2.all_todos if t.priority == "high"]
|
||||
assert any(t.milestone == "M1" for t in high_todos2)
|
||||
assert any(t.milestone == "M2" for t in high_todos2)
|
||||
|
||||
|
||||
class TestGoalsRoundtrip:
|
||||
"""Test GoalsParser round-trip preservation."""
|
||||
|
||||
def test_goals_roundtrip(self, tmp_path):
|
||||
"""Goals should be preserved across parse -> save -> parse."""
|
||||
goals_file = tmp_path / "goals.md"
|
||||
original_content = """---
|
||||
type: goals
|
||||
project: test-project
|
||||
updated: 2026-01-08
|
||||
---
|
||||
|
||||
# Goals
|
||||
|
||||
## Active
|
||||
|
||||
- [ ] Incomplete goal #high
|
||||
- [~] Partial goal #medium
|
||||
- [x] Completed goal #low
|
||||
|
||||
## Future
|
||||
|
||||
- [ ] Future goal 1 #medium
|
||||
- [ ] Future goal 2 #low
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- [ ] Not doing this #medium
|
||||
- [x] Confirmed non-goal #high
|
||||
"""
|
||||
goals_file.write_text(original_content)
|
||||
|
||||
# First parse
|
||||
parser1 = GoalsParser(goals_file)
|
||||
goal_list1 = parser1.parse()
|
||||
|
||||
# Save using GoalsSaver
|
||||
saver = GoalsSaver(goals_file, parser1.frontmatter)
|
||||
saver.save(goal_list1)
|
||||
|
||||
# Second parse
|
||||
parser2 = GoalsParser(goals_file)
|
||||
goal_list2 = parser2.parse()
|
||||
|
||||
# Compare counts
|
||||
assert len(goal_list1.active) == len(goal_list2.active)
|
||||
assert len(goal_list1.future) == len(goal_list2.future)
|
||||
assert len(goal_list1.non_goals) == len(goal_list2.non_goals)
|
||||
|
||||
# Compare active goals
|
||||
for g1, g2 in zip(goal_list1.active, goal_list2.active):
|
||||
assert g1.text == g2.text
|
||||
assert g1.completed == g2.completed
|
||||
assert g1.partial == g2.partial
|
||||
assert g1.priority == g2.priority
|
||||
|
||||
def test_three_state_goals_roundtrip(self, tmp_path):
|
||||
"""Three-state goals ([ ], [~], [x]) should be preserved."""
|
||||
goals_file = tmp_path / "goals.md"
|
||||
original_content = """---
|
||||
type: goals
|
||||
project: test
|
||||
---
|
||||
|
||||
# Goals
|
||||
|
||||
## Active
|
||||
|
||||
- [ ] Not started #medium
|
||||
- [~] In progress #high
|
||||
- [x] Done #low
|
||||
"""
|
||||
goals_file.write_text(original_content)
|
||||
|
||||
parser1 = GoalsParser(goals_file)
|
||||
goal_list1 = parser1.parse()
|
||||
|
||||
# Verify states parsed correctly
|
||||
assert goal_list1.active[0].completed == False
|
||||
assert goal_list1.active[0].partial == False
|
||||
assert goal_list1.active[1].completed == False
|
||||
assert goal_list1.active[1].partial == True
|
||||
assert goal_list1.active[2].completed == True
|
||||
assert goal_list1.active[2].partial == False
|
||||
|
||||
# Save and reparse
|
||||
saver = GoalsSaver(goals_file, parser1.frontmatter)
|
||||
saver.save(goal_list1)
|
||||
|
||||
parser2 = GoalsParser(goals_file)
|
||||
goal_list2 = parser2.parse()
|
||||
|
||||
# Verify states preserved
|
||||
assert goal_list2.active[0].completed == False
|
||||
assert goal_list2.active[0].partial == False
|
||||
assert goal_list2.active[1].completed == False
|
||||
assert goal_list2.active[1].partial == True
|
||||
assert goal_list2.active[2].completed == True
|
||||
assert goal_list2.active[2].partial == False
|
||||
|
||||
|
||||
class TestMilestonesRoundtrip:
|
||||
"""Test MilestonesParser round-trip preservation."""
|
||||
|
||||
def test_milestones_roundtrip(self, tmp_path):
|
||||
"""Milestones should be preserved across parse -> save -> parse."""
|
||||
milestones_file = tmp_path / "milestones.md"
|
||||
original_content = """---
|
||||
type: milestones
|
||||
project: test-project
|
||||
updated: 2026-01-08
|
||||
---
|
||||
|
||||
# Milestones
|
||||
|
||||
#### M1: First Milestone
|
||||
**Target**: January 2026
|
||||
**Status**: In Progress (50%)
|
||||
|
||||
First milestone description.
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| Task A | Done |
|
||||
| Task B | In Progress |
|
||||
| Task C | Not Started |
|
||||
|
||||
---
|
||||
|
||||
#### M2: Second Milestone
|
||||
**Target**: February 2026
|
||||
**Status**: Not Started
|
||||
|
||||
Second milestone description.
|
||||
|
||||
| Deliverable | Status |
|
||||
|-------------|--------|
|
||||
| Feature X | Not Started |
|
||||
| Feature Y | Not Started |
|
||||
|
||||
---
|
||||
"""
|
||||
milestones_file.write_text(original_content)
|
||||
|
||||
# First parse
|
||||
parser1 = MilestonesParser(milestones_file)
|
||||
milestones1 = parser1.parse()
|
||||
|
||||
# Save
|
||||
parser1.save(milestones1)
|
||||
|
||||
# Second parse
|
||||
parser2 = MilestonesParser(milestones_file)
|
||||
milestones2 = parser2.parse()
|
||||
|
||||
# Compare counts
|
||||
assert len(milestones1) == len(milestones2)
|
||||
|
||||
# Compare individual milestones
|
||||
for m1, m2 in zip(milestones1, milestones2):
|
||||
assert m1.id == m2.id
|
||||
assert m1.name == m2.name
|
||||
assert m1.target == m2.target
|
||||
assert m1.status == m2.status
|
||||
assert len(m1.deliverables) == len(m2.deliverables)
|
||||
|
||||
# Compare deliverables
|
||||
for d1, d2 in zip(m1.deliverables, m2.deliverables):
|
||||
assert d1.name == d2.name
|
||||
assert d1.status == d2.status
|
||||
|
||||
def test_milestone_status_roundtrip(self, tmp_path):
|
||||
"""Milestone statuses should be preserved."""
|
||||
milestones_file = tmp_path / "milestones.md"
|
||||
original_content = """---
|
||||
type: milestones
|
||||
project: test
|
||||
---
|
||||
|
||||
# Milestones
|
||||
|
||||
#### M1: Complete
|
||||
**Target**: Dec 2025
|
||||
**Status**: Completed (100%)
|
||||
|
||||
---
|
||||
|
||||
#### M2: In Progress
|
||||
**Target**: Jan 2026
|
||||
**Status**: In Progress (75%)
|
||||
|
||||
---
|
||||
|
||||
#### M3: Planning
|
||||
**Target**: Feb 2026
|
||||
**Status**: Planning (10%)
|
||||
|
||||
---
|
||||
|
||||
#### M4: Not Started
|
||||
**Target**: Q2 2026
|
||||
**Status**: Not Started
|
||||
|
||||
---
|
||||
"""
|
||||
milestones_file.write_text(original_content)
|
||||
|
||||
parser1 = MilestonesParser(milestones_file)
|
||||
milestones1 = parser1.parse()
|
||||
|
||||
# Create lookup by ID
|
||||
by_id1 = {m.id: m for m in milestones1}
|
||||
|
||||
# Verify statuses parsed
|
||||
assert by_id1["M1"].status == MilestoneStatus.COMPLETE
|
||||
assert by_id1["M2"].status == MilestoneStatus.IN_PROGRESS
|
||||
assert by_id1["M3"].status == MilestoneStatus.PLANNING
|
||||
assert by_id1["M4"].status == MilestoneStatus.NOT_STARTED
|
||||
|
||||
# Save and reparse
|
||||
parser1.save(milestones1)
|
||||
parser2 = MilestonesParser(milestones_file)
|
||||
milestones2 = parser2.parse()
|
||||
|
||||
# Create lookup by ID
|
||||
by_id2 = {m.id: m for m in milestones2}
|
||||
|
||||
# Verify statuses preserved (order may change due to Active/Completed sections)
|
||||
assert by_id2["M1"].status == MilestoneStatus.COMPLETE
|
||||
assert by_id2["M2"].status == MilestoneStatus.IN_PROGRESS
|
||||
assert by_id2["M3"].status == MilestoneStatus.PLANNING
|
||||
assert by_id2["M4"].status == MilestoneStatus.NOT_STARTED
|
||||
|
||||
|
||||
class TestAtomicWrite:
|
||||
"""Test atomic write functionality."""
|
||||
|
||||
def test_atomic_write_creates_file(self, tmp_path):
|
||||
"""atomic_write should create file if it doesn't exist."""
|
||||
from development_hub.parsers.base import atomic_write
|
||||
|
||||
test_file = tmp_path / "new_file.md"
|
||||
content = "# Test Content\n\nSome text here."
|
||||
|
||||
atomic_write(test_file, content)
|
||||
|
||||
assert test_file.exists()
|
||||
assert test_file.read_text() == content
|
||||
|
||||
def test_atomic_write_overwrites_file(self, tmp_path):
|
||||
"""atomic_write should safely overwrite existing file."""
|
||||
from development_hub.parsers.base import atomic_write
|
||||
|
||||
test_file = tmp_path / "existing.md"
|
||||
test_file.write_text("Original content")
|
||||
|
||||
new_content = "New content here"
|
||||
atomic_write(test_file, new_content)
|
||||
|
||||
assert test_file.read_text() == new_content
|
||||
|
||||
def test_atomic_write_no_temp_files_left(self, tmp_path):
|
||||
"""atomic_write should not leave temp files after success."""
|
||||
from development_hub.parsers.base import atomic_write
|
||||
|
||||
test_file = tmp_path / "test.md"
|
||||
atomic_write(test_file, "content")
|
||||
|
||||
# Check no temp files remain
|
||||
temp_files = list(tmp_path.glob(".test.md.*"))
|
||||
assert len(temp_files) == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Loading…
Reference in New Issue