diff --git a/src/development_hub/models/goal.py b/src/development_hub/models/goal.py index cfd902c..8268ad9 100644 --- a/src/development_hub/models/goal.py +++ b/src/development_hub/models/goal.py @@ -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 diff --git a/src/development_hub/models/todo.py b/src/development_hub/models/todo.py index 39c4c62..ec41c52 100644 --- a/src/development_hub/models/todo.py +++ b/src/development_hub/models/todo.py @@ -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: diff --git a/src/development_hub/parsers/base.py b/src/development_hub/parsers/base.py index 9b874c2..0ef7365 100644 --- a/src/development_hub/parsers/base.py +++ b/src/development_hub/parsers/base.py @@ -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. diff --git a/src/development_hub/parsers/goals_parser.py b/src/development_hub/parsers/goals_parser.py index 5cc0174..ce3e00d 100644 --- a/src/development_hub/parsers/goals_parser.py +++ b/src/development_hub/parsers/goals_parser.py @@ -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 "[ ]" diff --git a/src/development_hub/parsers/todos_parser.py b/src/development_hub/parsers/todos_parser.py index 0e7da62..21adbfd 100644 --- a/src/development_hub/parsers/todos_parser.py +++ b/src/development_hub/parsers/todos_parser.py @@ -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) diff --git a/src/development_hub/project_list.py b/src/development_hub/project_list.py index 0c333fc..583b352 100644 --- a/src/development_hub/project_list.py +++ b/src/development_hub/project_list.py @@ -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): diff --git a/src/development_hub/views/dashboard.py b/src/development_hub/views/dashboard.py index cb94660..44cd832 100644 --- a/src/development_hub/views/dashboard.py +++ b/src/development_hub/views/dashboard.py @@ -4,7 +4,9 @@ import subprocess import shutil from pathlib import Path -from PyQt6.QtCore import Qt, pyqtSignal, QFileSystemWatcher, QTimer +from development_hub.parsers.base import atomic_write + +from PyQt6.QtCore import Qt, pyqtSignal, QFileSystemWatcher, QTimer, QThread, QObject from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, @@ -15,11 +17,75 @@ from PyQt6.QtWidgets import ( QPushButton, QLineEdit, QComboBox, + QDialog, + QProgressBar, + QMessageBox, + QApplication, ) from development_hub.project_discovery import Project +from development_hub.services.health_checker import HealthChecker +from development_hub.parsers.progress_parser import ProgressLogManager +from development_hub.widgets.health_card import HealthCardCompact + + +class AuditWorker(QObject): + """Background worker for running goals audit.""" + + finished = pyqtSignal(str, bool) # output, success + error = pyqtSignal(str) + + def __init__(self, goals_content: str, project_path: Path | None = None): + super().__init__() + self.goals_content = goals_content + self.project_path = project_path + self._process: subprocess.Popen | None = None + self._cancelled = False + + def run(self): + """Execute the audit command.""" + cmdforge_path = Path.home() / "PycharmProjects" / "CmdForge" / ".venv" / "bin" / "cmdforge" + if not cmdforge_path.exists(): + cmdforge_path = Path("cmdforge") + + try: + self._process = subprocess.Popen( + [str(cmdforge_path), "run", "audit-goals"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=str(self.project_path) if self.project_path and self.project_path.exists() else None, + ) + + stdout, stderr = self._process.communicate(input=self.goals_content) + + if self._cancelled: + return + + if self._process.returncode == 0: + self.finished.emit(stdout, True) + else: + self.finished.emit(stderr or stdout, False) + + except Exception as e: + if not self._cancelled: + self.error.emit(str(e)) + + def cancel(self): + """Cancel the running process.""" + self._cancelled = True + if self._process: + self._process.terminate() + try: + self._process.wait(timeout=2) + except subprocess.TimeoutExpired: + self._process.kill() + + from development_hub.services.git_service import GitService from development_hub.services.health_checker import HealthChecker +from development_hub.models.health import HealthStatus from development_hub.parsers.todos_parser import TodosParser from development_hub.parsers.goals_parser import MilestonesParser, GoalsParser from development_hub.widgets.progress_bar import MilestoneProgressBar @@ -35,7 +101,7 @@ from development_hub.models.goal import Goal, GoalList, Milestone, Deliverable, class ProjectDashboard(QWidget): - """Dashboard view for a single project. + """Dashboard view for a single project or global overview. Displays: - Project health status and stats @@ -43,26 +109,38 @@ class ProjectDashboard(QWidget): - Milestones (time-bound checkpoints) - Recent activity, completed, blocked - TODOs by priority + + When project is None, displays global dashboard with: + - All projects health status + - Global goals/milestones/todos + - Needs attention section + - Today's progress """ terminal_requested = pyqtSignal(object) # Emits Project + project_selected = pyqtSignal(str) # Emits project_key (global mode only) + standup_requested = pyqtSignal() # Global mode only - def __init__(self, project: Project, parent: QWidget | None = None): + def __init__(self, project: Project | None = None, parent: QWidget | None = None): """Initialize project dashboard. Args: - project: Project to display + project: Project to display, or None for global dashboard parent: Parent widget """ super().__init__(parent) self.project = project + self.is_global = project is None self._docs_root = Path.home() / "PycharmProjects" / "project-docs" / "docs" # Todos state self._todos_parser = None self._todo_list = None self._todos_path = None - self._undo_stack = [] # List of (action_type, todo_text, priority, was_completed) + # Undo/redo stacks support todos, goals, and ideas + # Format: (action_type, ...action_specific_data) + # action_type: toggle, delete, edit (todos), goal_toggle, goal_delete, goal_edit, idea_toggle, idea_delete, idea_edit + self._undo_stack = [] self._redo_stack = [] self._ignoring_file_change = False # To avoid reload loops when we save @@ -76,6 +154,13 @@ class ProjectDashboard(QWidget): self._milestones_list = None self._milestones_path = None + # Audit worker state + self._audit_worker: AuditWorker | None = None + self._audit_thread: QThread | None = None + self._audit_dialog: QDialog | None = None + self._audit_elapsed_seconds = 0 + self._audit_timer: QTimer | None = None + # Filter state self._current_filter = "priority" # "priority", "milestone", or "all" self._milestone_sections = {} # For milestone filter view @@ -90,9 +175,34 @@ class ProjectDashboard(QWidget): self._reload_timer.setSingleShot(True) self._reload_timer.timeout.connect(self._do_reload_todos) + # Debounce timers for goals and milestones + self._reload_goals_timer = QTimer(self) + self._reload_goals_timer.setSingleShot(True) + self._reload_goals_timer.timeout.connect(self._do_reload_goals) + + self._reload_milestones_timer = QTimer(self) + self._reload_milestones_timer.setSingleShot(True) + self._reload_milestones_timer.timeout.connect(self._do_reload_milestones) + + # Git status refresh timer (every 3 minutes) + self._git_status_timer = QTimer(self) + self._git_status_timer.timeout.connect(self._refresh_git_status) + self._git_status_timer.start(180000) # 3 minutes in ms + + # Track last known git status for change detection + self._last_git_status = None + self._setup_ui() self._load_data() + def closeEvent(self, event): + """Clean up resources when widget is closed.""" + # Cancel any running audit + if self._audit_worker: + self._audit_worker.cancel() + self._cleanup_audit() + super().closeEvent(event) + def _setup_ui(self): """Set up the UI.""" # Main scroll area @@ -111,26 +221,89 @@ class ProjectDashboard(QWidget): # Header header_layout = QHBoxLayout() - title = QLabel(f"{self.project.title} Dashboard") + if self.is_global: + title = QLabel("Development Hub") + else: + title = QLabel(f"{self.project.title} Dashboard") title.setStyleSheet("font-size: 24px; font-weight: bold; color: #e0e0e0;") header_layout.addWidget(title) header_layout.addStretch() - # Health indicator + # Daily Standup button (global only) + if self.is_global: + 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) + + # Health indicator (project only) self.health_label = QLabel() self.health_label.setStyleSheet("font-size: 14px; color: #888888;") - header_layout.addWidget(self.health_label) + if not self.is_global: + header_layout.addWidget(self.health_label) layout.addLayout(header_layout) + # Overall milestones progress (global only) + if self.is_global: + milestone_section = QLabel("MILESTONES") + milestone_section.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888;") + layout.addWidget(milestone_section) + + self.global_milestone_progress = MilestoneProgressBar() + layout.addWidget(self.global_milestone_progress) + # Stats row self.stats_row = StatCardRow() - self.stats_row.add_stat("goals", 0, "Active\nGoals", "#4a9eff") - self.stats_row.add_stat("blocked", 0, "Blocked\nItems", "#ff9800") - self.stats_row.add_stat("todos", 0, "Open\nTODOs", "#e0e0e0") + if self.is_global: + self.stats_row.add_stat("healthy", 0, "Healthy", "#4caf50") + self.stats_row.add_stat("warning", 0, "Warning", "#ff9800") + self.stats_row.add_stat("todos", 0, "Total TODOs", "#e0e0e0") + else: + self.stats_row.add_stat("goals", 0, "Active\nGoals", "#4a9eff") + self.stats_row.add_stat("blocked", 0, "Blocked\nItems", "#ff9800") + self.stats_row.add_stat("todos", 0, "Open\nTODOs", "#e0e0e0") layout.addWidget(self.stats_row) + # Health alert banner (hidden by default) + self.alert_banner = QFrame() + self.alert_banner.setStyleSheet(""" + QFrame { + background-color: #4a3000; + border: 1px solid #ff9800; + border-radius: 6px; + padding: 8px; + } + """) + alert_layout = QHBoxLayout(self.alert_banner) + alert_layout.setContentsMargins(12, 8, 12, 8) + alert_layout.setSpacing(8) + + self.alert_icon = QLabel() + self.alert_icon.setStyleSheet("font-size: 16px;") + alert_layout.addWidget(self.alert_icon) + + self.alert_text = QLabel() + self.alert_text.setStyleSheet("color: #e0e0e0; font-size: 13px;") + self.alert_text.setWordWrap(True) + alert_layout.addWidget(self.alert_text, stretch=1) + + self.alert_banner.hide() + layout.addWidget(self.alert_banner) + # === GOALS SECTION === goals_header_layout = QHBoxLayout() goals_header_layout.setSpacing(8) @@ -165,6 +338,11 @@ class ProjectDashboard(QWidget): goals_header_layout.addStretch() + audit_goals_btn = QPushButton("Audit") + audit_goals_btn.setStyleSheet(self._button_style()) + audit_goals_btn.clicked.connect(self._audit_goals) + goals_header_layout.addWidget(audit_goals_btn) + edit_goals_btn = QPushButton("Edit") edit_goals_btn.setStyleSheet(self._button_style()) edit_goals_btn.clicked.connect(self._open_goals) @@ -172,12 +350,15 @@ class ProjectDashboard(QWidget): layout.addLayout(goals_header_layout) - # Goals container (will be populated by _load_goals) - self.goals_container = QWidget() - self.goals_layout = QVBoxLayout(self.goals_container) - self.goals_layout.setContentsMargins(16, 4, 0, 4) - self.goals_layout.setSpacing(4) - layout.addWidget(self.goals_container) + # Goals subsections (Active, Future, Non-Goals) + self.goals_active_section = CollapsibleSection("Active", expanded=True) + layout.addWidget(self.goals_active_section) + + self.goals_future_section = CollapsibleSection("Future", expanded=False) + layout.addWidget(self.goals_future_section) + + self.goals_nongoals_section = CollapsibleSection("Non-Goals", expanded=False) + layout.addWidget(self.goals_nongoals_section) # Add goal input goals_add_layout = QHBoxLayout() @@ -202,6 +383,57 @@ class ProjectDashboard(QWidget): self.goal_input.returnPressed.connect(self._add_goal) goals_add_layout.addWidget(self.goal_input) + # Section dropdown for goals + self.goal_section_combo = QComboBox() + self.goal_section_combo.addItem("active", "active") + self.goal_section_combo.addItem("future", "future") + self.goal_section_combo.addItem("non-goal", "non-goal") + self.goal_section_combo.setFixedWidth(70) + self.goal_section_combo.setStyleSheet(""" + QComboBox { + background-color: #2d2d2d; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 4px; + color: #e0e0e0; + font-size: 11px; + } + QComboBox::drop-down { border: none; } + QComboBox::down-arrow { + image: none; + border-left: 3px solid transparent; + border-right: 3px solid transparent; + border-top: 4px solid #888888; + } + """) + goals_add_layout.addWidget(self.goal_section_combo) + + # Priority dropdown for goals + self.goal_priority_combo = QComboBox() + self.goal_priority_combo.addItem("H", "high") + self.goal_priority_combo.addItem("M", "medium") + self.goal_priority_combo.addItem("L", "low") + self.goal_priority_combo.setCurrentIndex(0) + self.goal_priority_combo.setFixedWidth(40) + self.goal_priority_combo.setStyleSheet(""" + QComboBox { + background-color: #2d2d2d; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 4px; + color: #e0e0e0; + font-size: 11px; + } + QComboBox::drop-down { border: none; } + QComboBox::down-arrow { + image: none; + border-left: 3px solid transparent; + border-right: 3px solid transparent; + border-top: 4px solid #888888; + } + """) + goals_add_layout.addWidget(self.goal_priority_combo) + goal_add_btn = QPushButton("+") goal_add_btn.setFixedSize(28, 28) goal_add_btn.setStyleSheet(self._button_style()) @@ -287,14 +519,14 @@ class ProjectDashboard(QWidget): layout.addLayout(milestone_add_layout) - # === RECENT ACTIVITY SECTION === - self.activity_section = CollapsibleSection("RECENT ACTIVITY", expanded=False) - layout.addWidget(self.activity_section) - # === BLOCKED SECTION === self.blocked_section = CollapsibleSection("BLOCKED", expanded=False) layout.addWidget(self.blocked_section) + # === RECENT ACTIVITY SECTION === + self.activity_section = CollapsibleSection("RECENT ACTIVITY", expanded=False) + layout.addWidget(self.activity_section) + # === SEPARATOR === separator = QFrame() separator.setFrameShape(QFrame.Shape.HLine) @@ -432,6 +664,119 @@ class ProjectDashboard(QWidget): layout.addLayout(add_layout) + # === SEPARATOR (after TODOs) === + separator2 = QFrame() + separator2.setFrameShape(QFrame.Shape.HLine) + separator2.setStyleSheet("background-color: #3d3d3d;") + separator2.setFixedHeight(1) + layout.addWidget(separator2) + + # === IDEAS & EXPLORATION SECTION === + ideas_header_layout = QHBoxLayout() + ideas_header_layout.setSpacing(8) + ideas_label = QLabel("IDEAS & EXPLORATION") + ideas_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #e0e0e0;") + ideas_header_layout.addWidget(ideas_label) + ideas_header_layout.addStretch() + + edit_ideas_btn = QPushButton("Edit") + edit_ideas_btn.setStyleSheet(self._button_style()) + edit_ideas_btn.clicked.connect(self._open_ideas) + ideas_header_layout.addWidget(edit_ideas_btn) + + layout.addLayout(ideas_header_layout) + + self.ideas_section = CollapsibleSection("Ideas", expanded=False) + layout.addWidget(self.ideas_section) + + # Add idea input + ideas_add_layout = QHBoxLayout() + ideas_add_layout.setContentsMargins(16, 0, 0, 0) + ideas_add_layout.setSpacing(4) + + self.idea_input = QLineEdit() + self.idea_input.setPlaceholderText("Add new idea...") + self.idea_input.setStyleSheet(""" + QLineEdit { + background-color: #2d2d2d; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 6px 8px; + color: #e0e0e0; + font-size: 12px; + } + QLineEdit:focus { + border-color: #4a9eff; + } + """) + self.idea_input.returnPressed.connect(self._add_idea) + ideas_add_layout.addWidget(self.idea_input) + + # Priority dropdown for ideas + self.idea_priority_combo = QComboBox() + self.idea_priority_combo.addItem("H", "high") + self.idea_priority_combo.addItem("M", "medium") + self.idea_priority_combo.addItem("L", "low") + self.idea_priority_combo.setCurrentIndex(0) + self.idea_priority_combo.setFixedWidth(40) + self.idea_priority_combo.setStyleSheet(""" + QComboBox { + background-color: #2d2d2d; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 4px; + color: #e0e0e0; + font-size: 11px; + } + QComboBox::drop-down { border: none; } + QComboBox::down-arrow { + image: none; + border-left: 3px solid transparent; + border-right: 3px solid transparent; + border-top: 4px solid #888888; + } + """) + ideas_add_layout.addWidget(self.idea_priority_combo) + + idea_add_btn = QPushButton("+") + idea_add_btn.setFixedSize(28, 28) + idea_add_btn.setStyleSheet(self._button_style()) + idea_add_btn.clicked.connect(self._add_idea) + ideas_add_layout.addWidget(idea_add_btn) + + layout.addLayout(ideas_add_layout) + + # === GLOBAL-ONLY SECTIONS === + if self.is_global: + # 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) @@ -448,8 +793,126 @@ class ProjectDashboard(QWidget): def _load_data(self): """Load project data and update display.""" - # Get health info checker = HealthChecker() + + if self.is_global: + self._load_global_data(checker) + else: + self._load_project_data(checker) + + # Load common sections + self._load_goals() + self._load_milestones() + self._load_ideas() + self._load_todos() + + # Load project-only sections + if not self.is_global: + self._load_activity() + + def _load_global_data(self, checker: HealthChecker): + """Load global dashboard data.""" + 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("todos", ecosystem.total_active_todos) + + # Hide alert banner in global mode + self.alert_banner.hide() + + # Load global milestone progress (shows completion ratio, not single milestone) + milestones_path = self._docs_root / "goals" / "milestones.md" + if milestones_path.exists(): + parser = MilestonesParser(milestones_path) + all_milestones = parser.parse() + if all_milestones: + completed = sum(1 for m in all_milestones if m.is_complete) + total = len(all_milestones) + progress = int((completed / total) * 100) if total > 0 else 0 + self.global_milestone_progress.set_milestone( + f"{completed} of {total} Complete", + progress, + "", # No target date for overall progress + ) + else: + self.global_milestone_progress.clear() + else: + self.global_milestone_progress.clear() + + # Build project milestone progress lookup + project_milestone_progress = {} + for health in ecosystem.projects: + project_milestones_path = self._docs_root / "projects" / health.project_key / "milestones.md" + if project_milestones_path.exists(): + try: + proj_parser = MilestonesParser(project_milestones_path) + current_milestone = proj_parser.get_current_milestone() + if current_milestone: + project_milestone_progress[health.project_key] = current_milestone.progress + elif proj_parser.parse(): + milestones = proj_parser.parse() + total_progress = sum(m.progress for m in milestones) + project_milestone_progress[health.project_key] = total_progress // len(milestones) + except Exception: + pass + + # 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() + milestone_prog = project_milestone_progress.get(health.project_key, 0) + card.set_project( + health.project_key, + health.project_name, + health.status_icon, + health.git_info.time_since_commit, + health.active_todos, + milestone_prog, + ) + card.clicked.connect(self._on_project_clicked) + self.health_container.addWidget(card) + + # Needs attention + needing = ecosystem.get_needing_attention() + if needing: + attention_html = "
".join( + f"⚠ {h.project_name}: {', '.join(h.attention_reasons[:2])}" + for h in needing[:5] + ) + self.attention_list.setText(attention_html) + self.attention_list.setStyleSheet("color: #ff9800; line-height: 1.6;") + 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("
".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 _load_project_data(self, checker: HealthChecker): + """Load project-specific dashboard data.""" health = checker.check_project(self.project) # Update health label @@ -463,28 +926,58 @@ class ProjectDashboard(QWidget): self.stats_row.update_stat("blocked", health.blocked_todos) self.stats_row.update_stat("todos", health.active_todos) - # Load all sections - self._load_goals() - self._load_milestones() - self._load_activity() - self._load_todos() + # Update alert banner + if health.needs_attention: + reasons = health.attention_reasons + if health.status == HealthStatus.CRITICAL: + self.alert_banner.setStyleSheet(""" + QFrame { + background-color: #4a1a1a; + border: 1px solid #ff5252; + border-radius: 6px; + padding: 8px; + } + """) + self.alert_icon.setText("🔴") + self.alert_text.setText(f"Critical: {' • '.join(reasons)}") + else: + self.alert_banner.setStyleSheet(""" + QFrame { + background-color: #4a3000; + border: 1px solid #ff9800; + border-radius: 6px; + padding: 8px; + } + """) + self.alert_icon.setText("🟡") + self.alert_text.setText(f"Attention: {' • '.join(reasons)}") + + self.alert_banner.show() + else: + self.alert_banner.hide() + + def _on_project_clicked(self, project_key: str): + """Handle project card click (global mode only).""" + self.project_selected.emit(project_key) def _load_goals(self): - """Load and display goals.""" - # Clear existing goal widgets - while self.goals_layout.count(): - item = self.goals_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() + """Load and display goals in Active, Future, and Non-Goals subsections.""" + # Clear all goal subsections + self.goals_active_section.clear_content() + self.goals_future_section.clear_content() + self.goals_nongoals_section.clear_content() - goals_path = self._docs_root / "projects" / self.project.key / "goals.md" + if self.is_global: + goals_path = self._docs_root / "goals" / "goals.md" + else: + goals_path = self._docs_root / "projects" / self.project.key / "goals.md" self._goals_path = goals_path if not goals_path.exists(): no_goals = QLabel("No goals defined yet. Add one below!") no_goals.setStyleSheet("color: #666666; font-style: italic; font-size: 13px;") no_goals.setWordWrap(True) - self.goals_layout.addWidget(no_goals) + self.goals_active_section.add_widget(no_goals) self.goals_progress.setValue(0) self._goals_parser = None self._goal_list = None @@ -493,6 +986,7 @@ class ProjectDashboard(QWidget): self._goals_parser = GoalsParser(goals_path) self._goal_list = self._goals_parser.parse() + # Active goals completed_count = 0 total_count = len(self._goal_list.active) @@ -500,29 +994,73 @@ class ProjectDashboard(QWidget): widget = GoalItemWidget(goal) widget.toggled.connect(self._on_goal_toggled) widget.deleted.connect(self._on_goal_deleted) - self.goals_layout.addWidget(widget) + widget.edited.connect(self._on_goal_edited) + self.goals_active_section.add_widget(widget) if goal.completed: completed_count += 1 if not self._goal_list.active: - no_goals = QLabel("No active goals defined. Add one below!") + no_goals = QLabel("No active goals") no_goals.setStyleSheet("color: #666666; font-style: italic; font-size: 13px;") - self.goals_layout.addWidget(no_goals) + self.goals_active_section.add_widget(no_goals) - # Update progress bar + self.goals_active_section.set_count_ratio(completed_count, total_count) + + # Future goals (turn green when checked, no strikethrough) + future_checked = 0 + for goal in self._goal_list.future: + widget = GoalItemWidget(goal, is_non_goal=True) + widget.toggled.connect(self._on_goal_toggled) + widget.deleted.connect(self._on_goal_deleted) + widget.edited.connect(self._on_goal_edited) + self.goals_future_section.add_widget(widget) + if goal.completed: + future_checked += 1 + + if not self._goal_list.future: + no_future = QLabel("No future goals") + no_future.setStyleSheet("color: #666666; font-style: italic; font-size: 13px;") + self.goals_future_section.add_widget(no_future) + + self.goals_future_section.set_count_ratio(future_checked, len(self._goal_list.future)) + + # Non-goals (checkable, turn green when checked) + nongoals_checked = 0 + for goal in self._goal_list.non_goals: + widget = GoalItemWidget(goal, is_non_goal=True) + widget.toggled.connect(self._on_goal_toggled) + widget.deleted.connect(self._on_goal_deleted) + widget.edited.connect(self._on_goal_edited) + self.goals_nongoals_section.add_widget(widget) + if goal.completed: + nongoals_checked += 1 + + if not self._goal_list.non_goals: + no_nongoals = QLabel("No non-goals defined") + no_nongoals.setStyleSheet("color: #666666; font-style: italic; font-size: 13px;") + self.goals_nongoals_section.add_widget(no_nongoals) + + self.goals_nongoals_section.set_count_ratio(nongoals_checked, len(self._goal_list.non_goals)) + + # Update progress bar (active goals only) progress = int((completed_count / total_count) * 100) if total_count > 0 else 0 self.goals_progress.setValue(progress) def _load_milestones(self): """Load and display milestones with linked todos.""" + from development_hub.models.goal import MilestoneStatus + # Clear existing milestone widgets while self.milestones_layout.count(): item = self.milestones_layout.takeAt(0) if item.widget(): item.widget().deleteLater() - # Use project-specific milestones - milestones_path = self._docs_root / "projects" / self.project.key / "milestones.md" + # Use appropriate milestones path + if self.is_global: + milestones_path = self._docs_root / "goals" / "milestones.md" + else: + milestones_path = self._docs_root / "projects" / self.project.key / "milestones.md" self._milestones_path = milestones_path if not milestones_path.exists(): @@ -538,20 +1076,22 @@ class ProjectDashboard(QWidget): self._milestones_list = self._milestones_parser.parse() # Load todos to get linked items for each milestone - todos_path = self._docs_root / "projects" / self.project.key / "todos.md" + if self.is_global: + todos_path = self._docs_root / "todos.md" + else: + todos_path = self._docs_root / "projects" / self.project.key / "todos.md" todo_list = None if todos_path.exists(): parser = TodosParser(todos_path) todo_list = parser.parse() - # Create MilestoneWidget for each milestone with linked todos - for i, milestone in enumerate(self._milestones_list): - # First active milestone is expanded by default - from development_hub.models.goal import MilestoneStatus - is_first_active = ( - milestone.status in (MilestoneStatus.IN_PROGRESS, MilestoneStatus.NOT_STARTED, MilestoneStatus.PLANNING) - and i == 0 - ) + # Create MilestoneWidget for each milestone (keep original file order) + first_active_shown = False + for milestone in self._milestones_list: + # First in-progress milestone is expanded by default + is_first_active = not first_active_shown and milestone.status == MilestoneStatus.IN_PROGRESS + if is_first_active: + first_active_shown = True # Get todos linked to this milestone linked_todos = [] @@ -559,15 +1099,7 @@ class ProjectDashboard(QWidget): linked_todos = todo_list.get_by_milestone(milestone.id) widget = MilestoneWidget(milestone, expanded=is_first_active, todos=linked_todos) - # Connect signals for todo mode - widget.todo_toggled.connect(self._on_milestone_todo_toggled) - widget.todo_deleted.connect(self._on_milestone_todo_deleted) - widget.todo_added.connect(self._on_milestone_todo_added) - widget.todo_start_discussion.connect(self._on_todo_start_discussion) - # Keep legacy signals for deliverables mode (fallback) - widget.deliverable_toggled.connect(self._on_deliverable_toggled) - widget.deliverable_added.connect(self._on_deliverable_added) - widget.deliverable_deleted.connect(self._on_deliverable_deleted) + self._connect_milestone_signals(widget) self.milestones_layout.addWidget(widget) if not self._milestones_list: @@ -577,22 +1109,100 @@ class ProjectDashboard(QWidget): self.milestones_progress.setValue(0) return - # Calculate overall milestones progress based on linked todos - total_todos = 0 - completed_todos = 0 - if todo_list: - for milestone in self._milestones_list: - linked = todo_list.get_by_milestone(milestone.id) - total_todos += len(linked) - completed_todos += sum(1 for t in linked if t.completed) - - progress = int((completed_todos / total_todos) * 100) if total_todos > 0 else 0 + # Calculate overall milestones progress as ratio of complete milestones + total_milestones = len(self._milestones_list) + completed_milestones = sum(1 for m in self._milestones_list if m.is_complete) + progress = int((completed_milestones / total_milestones) * 100) if total_milestones > 0 else 0 self.milestones_progress.setValue(progress) + def _connect_milestone_signals(self, widget: MilestoneWidget): + """Connect signals for a milestone widget.""" + widget.todo_toggled.connect(self._on_milestone_todo_toggled) + widget.todo_deleted.connect(self._on_milestone_todo_deleted) + widget.todo_added.connect(self._on_milestone_todo_added) + widget.todo_start_discussion.connect(self._on_todo_start_discussion) + widget.todo_edited.connect(self._on_todo_edited) + # Keep legacy signals for deliverables mode (fallback) + widget.deliverable_toggled.connect(self._on_deliverable_toggled) + widget.deliverable_added.connect(self._on_deliverable_added) + widget.deliverable_deleted.connect(self._on_deliverable_deleted) + + def _load_ideas(self): + """Load and display ideas from ideas-and-exploration.md.""" + self.ideas_section.clear_content() + + if self.is_global: + ideas_path = self._docs_root / "goals" / "ideas-and-exploration.md" + else: + ideas_path = self._docs_root / "projects" / self.project.key / "ideas-and-exploration.md" + self._ideas_path = ideas_path + + if not ideas_path.exists(): + no_ideas = QLabel("No ideas file found") + no_ideas.setStyleSheet("color: #666666; font-style: italic;") + self.ideas_section.add_widget(no_ideas) + self.ideas_section.set_count(0) + self._ideas_list = [] + return + + # Parse the ideas file - look for checkbox items in Ideas section + content = ideas_path.read_text() + self._ideas_list = [] + in_ideas_section = False + + for line in content.split("\n"): + stripped = line.strip() + if stripped.startswith("## Ideas"): + in_ideas_section = True + continue + elif stripped.startswith("## "): + in_ideas_section = False + continue + + if in_ideas_section and stripped.startswith("- ["): + # Parse checkbox: - [ ] or - [x] + completed = stripped.startswith("- [x]") + text = stripped[6:].strip() if completed else stripped[5:].strip() + if text: + # Extract priority from hashtag + priority = "medium" + for tag in ["#high", "#medium", "#low"]: + if tag in text: + priority = tag[1:] # Remove the # + text = text.replace(tag, "").strip() + break + idea = Goal(text=text, completed=completed, priority=priority) + self._ideas_list.append(idea) + + if self._ideas_list: + checked_count = 0 + for idea in self._ideas_list: + widget = GoalItemWidget(idea, is_non_goal=True) + widget.toggled.connect(self._on_idea_toggled) + widget.deleted.connect(self._on_idea_deleted) + widget.edited.connect(self._on_idea_edited) + self.ideas_section.add_widget(widget) + if idea.completed: + checked_count += 1 + self.ideas_section.set_count_ratio(checked_count, len(self._ideas_list)) + else: + no_ideas = QLabel("No ideas yet") + no_ideas.setStyleSheet("color: #666666; font-style: italic;") + self.ideas_section.add_widget(no_ideas) + self.ideas_section.set_count(0) + def _load_activity(self): """Load recent git activity.""" self.activity_section.clear_content() + if self.is_global: + # No git activity in global mode (no single project context) + no_activity = QLabel("Select a project to view git activity") + no_activity.setStyleSheet("color: #666666; font-style: italic;") + self.activity_section.add_widget(no_activity) + self.activity_section.set_count(0) + return + if self.project.exists: git_service = GitService(self.project.path) commits = git_service.get_recent_commits(5) @@ -629,10 +1239,13 @@ class ProjectDashboard(QWidget): section.deleteLater() self._milestone_sections.clear() - todos_path = self._docs_root / "projects" / self.project.key / "todos.md" + if self.is_global: + todos_path = self._docs_root / "todos.md" + else: + todos_path = self._docs_root / "projects" / self.project.key / "todos.md" self._todos_path = todos_path - # Set up file watching - watch both file and directory for robustness + # Set up file watching - watch todos, goals, milestones and directory watched_files = self._file_watcher.files() watched_dirs = self._file_watcher.directories() if watched_files: @@ -640,8 +1253,12 @@ class ProjectDashboard(QWidget): if watched_dirs: self._file_watcher.removePaths(watched_dirs) - if todos_path.exists(): - self._file_watcher.addPath(str(todos_path)) + # Watch all doc files + for file_path in [self._todos_path, self._goals_path, self._milestones_path]: + if file_path and file_path.exists(): + self._file_watcher.addPath(str(file_path)) + + # Watch directory for file recreation if todos_path.parent.exists(): self._file_watcher.addPath(str(todos_path.parent)) @@ -679,6 +1296,7 @@ class ProjectDashboard(QWidget): widget = TodoItemWidget(todo) widget.toggled.connect(self._on_todo_toggled) widget.deleted.connect(self._on_todo_deleted) + widget.edited.connect(self._on_todo_edited) widget.start_discussion.connect(self._on_todo_start_discussion) self.blocked_section.add_widget(widget) self.blocked_section.set_count(len(self._todo_list.blocked)) @@ -687,6 +1305,7 @@ class ProjectDashboard(QWidget): widget = TodoItemWidget(todo) widget.toggled.connect(self._on_todo_toggled) widget.deleted.connect(self._on_todo_deleted) + widget.edited.connect(self._on_todo_edited) widget.start_discussion.connect(self._on_todo_start_discussion) self.completed_section.add_widget(widget) self.completed_section.set_count(len(self._todo_list.completed)) @@ -704,6 +1323,7 @@ class ProjectDashboard(QWidget): widget = TodoItemWidget(todo) widget.toggled.connect(self._on_todo_toggled) widget.deleted.connect(self._on_todo_deleted) + widget.edited.connect(self._on_todo_edited) widget.start_discussion.connect(self._on_todo_start_discussion) self.high_section.add_widget(widget) high_active += 1 @@ -718,6 +1338,7 @@ class ProjectDashboard(QWidget): widget = TodoItemWidget(todo) widget.toggled.connect(self._on_todo_toggled) widget.deleted.connect(self._on_todo_deleted) + widget.edited.connect(self._on_todo_edited) widget.start_discussion.connect(self._on_todo_start_discussion) self.medium_section.add_widget(widget) med_active += 1 @@ -732,6 +1353,7 @@ class ProjectDashboard(QWidget): widget = TodoItemWidget(todo) widget.toggled.connect(self._on_todo_toggled) widget.deleted.connect(self._on_todo_deleted) + widget.edited.connect(self._on_todo_edited) widget.start_discussion.connect(self._on_todo_start_discussion) self.low_section.add_widget(widget) low_active += 1 @@ -767,6 +1389,7 @@ class ProjectDashboard(QWidget): widget = TodoItemWidget(todo) widget.toggled.connect(self._on_todo_toggled) widget.deleted.connect(self._on_todo_deleted) + widget.edited.connect(self._on_todo_edited) widget.start_discussion.connect(self._on_todo_start_discussion) section.add_widget(widget) section.set_count(len(todos_for_milestone)) @@ -783,6 +1406,7 @@ class ProjectDashboard(QWidget): widget = TodoItemWidget(todo) widget.toggled.connect(self._on_todo_toggled) widget.deleted.connect(self._on_todo_deleted) + widget.edited.connect(self._on_todo_edited) widget.start_discussion.connect(self._on_todo_start_discussion) section.add_widget(widget) section.set_count(len(no_milestone_todos)) @@ -818,6 +1442,7 @@ class ProjectDashboard(QWidget): widget = TodoItemWidget(todo) widget.toggled.connect(self._on_todo_toggled) widget.deleted.connect(self._on_todo_deleted) + widget.edited.connect(self._on_todo_edited) widget.start_discussion.connect(self._on_todo_start_discussion) self.high_section.add_widget(widget) @@ -835,6 +1460,12 @@ class ProjectDashboard(QWidget): if not text: return + # Cannot add todos in global mode (no project context) + if self.is_global: + self.toast.show_message("Cannot add todos in global view") + self._position_toast() + return + # Create parser if needed todos_path = self._docs_root / "projects" / self.project.key / "todos.md" if not self._todos_parser: @@ -861,13 +1492,26 @@ class ProjectDashboard(QWidget): self._undo_stack.append(("toggle", todo.text, todo.priority, not completed)) self._redo_stack.clear() # New action clears redo stack - # Update the todo in self._todo_list (may be different instance) + # Find and update the todo, moving it between lists as needed for t in self._todo_list.all_todos: if t.text == todo.text and t.priority == todo.priority: - t.completed = completed + # Remove from current list + self._todo_list.remove_todo(t) + + if completed: + # Mark as complete and add to completed list + t.mark_complete() + self._todo_list.completed.append(t) + else: + # Uncompleting - restore to active list + # Priority is preserved (stored as #high/#medium/#low tag) + t.completed = False + t.completed_date = None + self._todo_list.add_todo(t) break self._save_todos() + self._sync_milestone_status_from_todos() # Update milestone status in file self._load_todos() self._load_milestones() # Sync milestone view @@ -884,8 +1528,8 @@ class ProjectDashboard(QWidget): def _on_todo_deleted(self, todo): """Handle todo deletion.""" if self._todo_list and self._todos_parser: - # Store for undo: (action, text, priority, was_completed) - self._undo_stack.append(("delete", todo.text, todo.priority, todo.completed)) + # Store for undo: (action, text, priority, was_completed, milestone) + self._undo_stack.append(("delete", todo.text, todo.priority, todo.completed, todo.milestone)) self._redo_stack.clear() self._todo_list.remove_todo(todo) @@ -901,10 +1545,49 @@ class ProjectDashboard(QWidget): ) self._position_toast() + def _on_todo_edited(self, todo, old_text: str, new_text: str): + """Handle todo text edited inline.""" + if self._todo_list and self._todos_parser: + # Store for undo: (action, old_text, new_text, priority, was_completed) + self._undo_stack.append(("edit", old_text, todo.priority, todo.completed, new_text)) + self._redo_stack.clear() + + # Update the todo in self._todo_list + for t in self._todo_list.all_todos: + if t.text == old_text and t.priority == todo.priority: + t.text = new_text + break + + # Also update the passed todo object + todo.text = new_text + + self._save_todos() + self._load_todos() + + # Show toast with undo option + truncated_old = old_text[:20] + "..." if len(old_text) > 20 else old_text + truncated_new = new_text[:20] + "..." if len(new_text) > 20 else new_text + self.toast.show_message( + f"Edited: {truncated_old} → {truncated_new}", + can_undo=bool(self._undo_stack), + can_redo=bool(self._redo_stack), + ) + self._position_toast() + def _on_todo_start_discussion(self, todo): """Handle starting a discussion for a todo item.""" from PyQt6.QtWidgets import QMessageBox + # Cannot start discussion in global mode (no project context) + if self.is_global: + QMessageBox.warning( + self, + "Not Available", + "Cannot start discussion in global view.\n\n" + "Open a project dashboard to start discussions." + ) + return + # Generate title from todo text title = todo.text.lower().replace(" ", "-") title = "".join(c for c in title if c.isalnum() or c == "-") @@ -971,8 +1654,14 @@ class ProjectDashboard(QWidget): self._ensure_file_watched() return - # Debounce - some editors trigger multiple events - self._reload_timer.start(100) + # Determine which file changed and trigger appropriate reload + path_obj = Path(path) + if path_obj.name == "todos.md": + self._reload_timer.start(100) + elif path_obj.name == "goals.md": + self._reload_goals_timer.start(100) + elif path_obj.name == "milestones.md": + self._reload_milestones_timer.start(100) # Re-add path since QFileSystemWatcher removes it after change self._ensure_file_watched() @@ -984,19 +1673,23 @@ class ProjectDashboard(QWidget): self._ensure_file_watched() return - # Check if our file was recreated - if self._todos_path and self._todos_path.exists(): - # Re-add the file to the watcher - if str(self._todos_path) not in self._file_watcher.files(): - self._file_watcher.addPath(str(self._todos_path)) - # Debounce reload - self._reload_timer.start(100) + # Check if any of our files were recreated + for file_path, timer in [ + (self._todos_path, self._reload_timer), + (self._goals_path, self._reload_goals_timer), + (self._milestones_path, self._reload_milestones_timer), + ]: + if file_path and file_path.exists(): + if str(file_path) not in self._file_watcher.files(): + self._file_watcher.addPath(str(file_path)) + timer.start(100) def _ensure_file_watched(self): - """Ensure the todos file is being watched.""" - if self._todos_path and self._todos_path.exists(): - if str(self._todos_path) not in self._file_watcher.files(): - self._file_watcher.addPath(str(self._todos_path)) + """Ensure all doc files are being watched.""" + for file_path in [self._todos_path, self._goals_path, self._milestones_path]: + if file_path and file_path.exists(): + if str(file_path) not in self._file_watcher.files(): + self._file_watcher.addPath(str(file_path)) def _do_reload_todos(self): """Actually reload todos after debounce timer expires.""" @@ -1009,45 +1702,211 @@ class ProjectDashboard(QWidget): ) self._position_toast() - def _undo(self): - """Undo the last action.""" - if not self._undo_stack or not self._todos_parser: + def _do_reload_goals(self): + """Reload goals after debounce timer expires.""" + self._load_goals() + self.toast.show_message( + "Goals reloaded (external change)", + can_undo=False, + can_redo=False, + duration_ms=3000, + ) + self._position_toast() + + def _do_reload_milestones(self): + """Reload milestones after debounce timer expires.""" + self._load_milestones() + self.toast.show_message( + "Milestones reloaded (external change)", + can_undo=False, + can_redo=False, + duration_ms=3000, + ) + self._position_toast() + + def _refresh_git_status(self): + """Refresh git status and update health display if changed.""" + # Skip git status refresh in global mode + if self.is_global: return - # Reload fresh from file to ensure we have current state - self._todo_list = self._todos_parser.parse() + checker = HealthChecker() + health = checker.check_project(self.project) - action_type, text, priority, was_completed = self._undo_stack.pop() + # Build a status tuple to compare + current_status = ( + health.status, + health.git_info.last_commit_date, + health.git_info.uncommitted_changes, + ) - if action_type == "toggle": - # Find the todo by text - for todo in self._todo_list.all_todos: - if todo.text == text: - # Store current state for redo - self._redo_stack.append(("toggle", text, priority, todo.completed)) - # Remove from current location - self._todo_list.remove_todo(todo) - # Restore BOTH completion state AND priority - todo.completed = was_completed - todo.priority = priority # Critical: restore original priority! - if was_completed: - todo.mark_complete() - else: - todo.completed_date = None - # Re-add to correct list based on restored state - self._todo_list.add_todo(todo) - break + # Only update UI if something changed + if current_status != self._last_git_status: + self._last_git_status = current_status - elif action_type == "delete": - # Restore deleted todo with original priority - from development_hub.models.todo import Todo - restored = Todo(text=text, priority=priority, completed=was_completed) - self._todo_list.add_todo(restored) - self._redo_stack.append(("delete", text, priority, was_completed)) + # Update health label + self.health_label.setText( + f"{health.status_icon} {health.status.value.title()} " + f"Last commit: {health.git_info.time_since_commit}" + ) - self._save_todos() - self._load_todos() - self._load_milestones() # Sync milestone view + # Update alert banner + if health.needs_attention: + reasons = health.attention_reasons + if health.status == HealthStatus.CRITICAL: + self.alert_banner.setStyleSheet(""" + QFrame { + background-color: #4a1a1a; + border: 1px solid #ff5252; + border-radius: 6px; + padding: 8px; + } + """) + self.alert_icon.setText("🔴") + self.alert_text.setText(f"Critical: {' • '.join(reasons)}") + else: + self.alert_banner.setStyleSheet(""" + QFrame { + background-color: #4a3000; + border: 1px solid #ff9800; + border-radius: 6px; + padding: 8px; + } + """) + self.alert_icon.setText("🟡") + self.alert_text.setText(f"Attention: {' • '.join(reasons)}") + self.alert_banner.show() + else: + self.alert_banner.hide() + + def showEvent(self, event): + """Handle show event - refresh git status when tab becomes visible.""" + super().showEvent(event) + # Check git status when dashboard becomes visible (e.g., switching tabs) + self._refresh_git_status() + + def _undo(self): + """Undo the last action.""" + if not self._undo_stack: + return + + action = self._undo_stack.pop() + action_type = action[0] + + # Handle todo actions + if action_type in ("toggle", "delete", "edit"): + if not self._todos_parser: + return + # Reload fresh from file to ensure we have current state + self._todo_list = self._todos_parser.parse() + + if action_type == "toggle": + _, text, priority, was_completed = action + for todo in self._todo_list.all_todos: + if todo.text == text: + self._redo_stack.append(("toggle", text, priority, todo.completed)) + self._todo_list.remove_todo(todo) + todo.completed = was_completed + todo.priority = priority + if was_completed: + todo.mark_complete() + else: + todo.completed_date = None + self._todo_list.add_todo(todo) + break + + elif action_type == "delete": + _, text, priority, was_completed = action[:4] + milestone = action[4] if len(action) > 4 else None + from development_hub.models.todo import Todo + restored = Todo(text=text, priority=priority, completed=was_completed, milestone=milestone) + self._todo_list.add_todo(restored) + self._redo_stack.append(("delete", text, priority, was_completed, milestone)) + + elif action_type == "edit": + _, old_text, priority, was_completed, new_text = action + for todo in self._todo_list.all_todos: + if todo.text == new_text and todo.priority == priority: + self._redo_stack.append(("edit", old_text, priority, was_completed, new_text)) + todo.text = old_text + break + + self._save_todos() + self._load_todos() + self._load_milestones() + + # Handle goal actions + elif action_type in ("goal_toggle", "goal_delete", "goal_edit"): + if not self._goals_parser or not self._goal_list: + return + + if action_type == "goal_toggle": + _, text, priority, was_completed, was_partial, section = action + # Find goal and restore state + section_list = getattr(self._goal_list, section, self._goal_list.active) + for goal in section_list: + if goal.text == text: + # Store current state for redo + self._redo_stack.append(("goal_toggle", text, priority, goal.completed, getattr(goal, 'partial', False), section)) + goal.completed = was_completed + goal.partial = was_partial + break + + elif action_type == "goal_delete": + _, text, priority, was_completed, was_partial, section = action + # Restore deleted goal + from development_hub.models.goal import Goal + restored = Goal(text=text, priority=priority, completed=was_completed, partial=was_partial) + section_list = getattr(self._goal_list, section, self._goal_list.active) + section_list.append(restored) + self._redo_stack.append(("goal_delete", text, priority, was_completed, was_partial, section)) + + elif action_type == "goal_edit": + _, old_text, new_text, priority, completed, partial, section = action + # Find goal by new_text and restore old_text + section_list = getattr(self._goal_list, section, self._goal_list.active) + for goal in section_list: + if goal.text == new_text: + self._redo_stack.append(("goal_edit", old_text, new_text, priority, completed, partial, section)) + goal.text = old_text + break + + self._save_goals() + self._load_goals() + + # Handle idea actions + elif action_type in ("idea_toggle", "idea_delete", "idea_edit"): + if not hasattr(self, '_ideas_list') or not self._ideas_list: + # For idea_delete undo, we need to restore even if list is empty + if action_type != "idea_delete": + return + self._ideas_list = [] + + if action_type == "idea_toggle": + _, text, priority, was_completed = action + for idea in self._ideas_list: + if idea.text == text: + self._redo_stack.append(("idea_toggle", text, priority, idea.completed)) + idea.completed = was_completed + break + + elif action_type == "idea_delete": + _, text, priority, was_completed = action + from development_hub.models.goal import Goal + restored = Goal(text=text, priority=priority, completed=was_completed) + self._ideas_list.append(restored) + self._redo_stack.append(("idea_delete", text, priority, was_completed)) + + elif action_type == "idea_edit": + _, old_text, new_text, priority, completed = action + for idea in self._ideas_list: + if idea.text == new_text: + self._redo_stack.append(("idea_edit", old_text, new_text, priority, completed)) + idea.text = old_text + break + + self._save_ideas() + self._load_ideas() self.toast.show_message( "Undone", @@ -1058,42 +1917,122 @@ class ProjectDashboard(QWidget): def _redo(self): """Redo the last undone action.""" - if not self._redo_stack or not self._todos_parser: + if not self._redo_stack: return - # Reload fresh from file - self._todo_list = self._todos_parser.parse() + action = self._redo_stack.pop() + action_type = action[0] - action_type, text, priority, was_completed = self._redo_stack.pop() + # Handle todo actions + if action_type in ("toggle", "delete", "edit"): + if not self._todos_parser: + return + self._todo_list = self._todos_parser.parse() - if action_type == "toggle": - # Find the todo by text - for todo in self._todo_list.all_todos: - if todo.text == text: - # Store current state for undo - self._undo_stack.append(("toggle", text, priority, todo.completed)) - self._todo_list.remove_todo(todo) - # Redo restores to the state before undo (was_completed) - todo.completed = was_completed - todo.priority = priority - if todo.completed: - todo.mark_complete() - else: - todo.completed_date = None - self._todo_list.add_todo(todo) - break + if action_type == "toggle": + _, text, priority, was_completed = action + for todo in self._todo_list.all_todos: + if todo.text == text: + self._undo_stack.append(("toggle", text, priority, todo.completed)) + self._todo_list.remove_todo(todo) + todo.completed = was_completed + todo.priority = priority + if todo.completed: + todo.mark_complete() + else: + todo.completed_date = None + self._todo_list.add_todo(todo) + break - elif action_type == "delete": - # Delete again - for todo in self._todo_list.all_todos: - if todo.text == text: - self._undo_stack.append(("delete", text, priority, todo.completed)) - self._todo_list.remove_todo(todo) - break + elif action_type == "delete": + _, text, priority, was_completed = action[:4] + milestone = action[4] if len(action) > 4 else None + for todo in self._todo_list.all_todos: + if todo.text == text: + self._undo_stack.append(("delete", text, priority, todo.completed, todo.milestone)) + self._todo_list.remove_todo(todo) + break - self._save_todos() - self._load_todos() - self._load_milestones() # Sync milestone view + elif action_type == "edit": + _, old_text, priority, was_completed, new_text = action + for todo in self._todo_list.all_todos: + if todo.text == old_text and todo.priority == priority: + self._undo_stack.append(("edit", old_text, priority, was_completed, new_text)) + todo.text = new_text + break + + self._save_todos() + self._load_todos() + self._load_milestones() + + # Handle goal actions + elif action_type in ("goal_toggle", "goal_delete", "goal_edit"): + if not self._goals_parser or not self._goal_list: + return + + if action_type == "goal_toggle": + _, text, priority, was_completed, was_partial, section = action + section_list = getattr(self._goal_list, section, self._goal_list.active) + for goal in section_list: + if goal.text == text: + self._undo_stack.append(("goal_toggle", text, priority, goal.completed, getattr(goal, 'partial', False), section)) + goal.completed = was_completed + goal.partial = was_partial + break + + elif action_type == "goal_delete": + _, text, priority, was_completed, was_partial, section = action + # Delete again + section_list = getattr(self._goal_list, section, self._goal_list.active) + for goal in section_list[:]: # Copy list to allow modification + if goal.text == text: + self._undo_stack.append(("goal_delete", text, priority, goal.completed, getattr(goal, 'partial', False), section)) + section_list.remove(goal) + break + + elif action_type == "goal_edit": + _, old_text, new_text, priority, completed, partial, section = action + section_list = getattr(self._goal_list, section, self._goal_list.active) + for goal in section_list: + if goal.text == old_text: + self._undo_stack.append(("goal_edit", old_text, new_text, priority, completed, partial, section)) + goal.text = new_text + break + + self._save_goals() + self._load_goals() + + # Handle idea actions + elif action_type in ("idea_toggle", "idea_delete", "idea_edit"): + if not hasattr(self, '_ideas_list') or not self._ideas_list: + return + + if action_type == "idea_toggle": + _, text, priority, was_completed = action + for idea in self._ideas_list: + if idea.text == text: + self._undo_stack.append(("idea_toggle", text, priority, idea.completed)) + idea.completed = was_completed + break + + elif action_type == "idea_delete": + _, text, priority, was_completed = action + for idea in self._ideas_list[:]: + if idea.text == text: + self._undo_stack.append(("idea_delete", text, priority, idea.completed)) + self._ideas_list.remove(idea) + break + + elif action_type == "idea_edit": + _, old_text, new_text, priority, completed = action + for idea in self._ideas_list: + if idea.text == old_text: + self._undo_stack.append(("idea_edit", old_text, new_text, priority, completed)) + idea.text = new_text + break + + self._save_ideas() + self._load_ideas() self.toast.show_message( "Redone", @@ -1124,6 +2063,108 @@ class ProjectDashboard(QWidget): if self.toast.isVisible(): self._position_toast() + def keyPressEvent(self, event): + """Handle keyboard shortcuts for the dashboard. + + Shortcuts: + / - Focus add todo input + Ctrl+1 - Priority filter view + Ctrl+2 - Milestone filter view + Ctrl+3 - Show all todos + Ctrl+[ - Collapse all sections + Ctrl+] - Expand all sections + """ + key = event.key() + modifiers = event.modifiers() + ctrl = modifiers & Qt.KeyboardModifier.ControlModifier + + # / - Focus add todo input + if key == Qt.Key.Key_Slash and not ctrl: + self.todo_input.setFocus() + self.todo_input.selectAll() + event.accept() + return + + # Ctrl+1 - Priority filter + if ctrl and key == Qt.Key.Key_1: + self.todo_filter.setCurrentIndex(0) # "By Priority" + event.accept() + return + + # Ctrl+2 - Milestone filter + if ctrl and key == Qt.Key.Key_2: + self.todo_filter.setCurrentIndex(1) # "By Milestone" + event.accept() + return + + # Ctrl+3 - Show all + if ctrl and key == Qt.Key.Key_3: + self.todo_filter.setCurrentIndex(2) # "Show All" + event.accept() + return + + # Ctrl+[ - Collapse all sections + if ctrl and key == Qt.Key.Key_BracketLeft: + self._collapse_all_sections() + event.accept() + return + + # Ctrl+] - Expand all sections + if ctrl and key == Qt.Key.Key_BracketRight: + self._expand_all_sections() + event.accept() + return + + super().keyPressEvent(event) + + def _expand_all_sections(self): + """Expand all collapsible sections.""" + sections = [ + self.goals_active_section, + self.goals_future_section, + self.goals_nongoals_section, + self.ideas_section, + self.activity_section, + self.blocked_section, + self.high_section, + self.medium_section, + self.low_section, + self.completed_section, + ] + # Also include milestone sections if in milestone view + sections.extend(self._milestone_sections.values()) + + for section in sections: + if section.isVisible(): + section.set_expanded(True) + + self.toast.show_message("All sections expanded", can_undo=False, can_redo=False) + self._position_toast() + + def _collapse_all_sections(self): + """Collapse all collapsible sections.""" + sections = [ + self.goals_active_section, + self.goals_future_section, + self.goals_nongoals_section, + self.ideas_section, + self.activity_section, + self.blocked_section, + self.high_section, + self.medium_section, + self.low_section, + self.completed_section, + ] + # Also include milestone sections if in milestone view + sections.extend(self._milestone_sections.values()) + + for section in sections: + if section.isVisible(): + section.set_expanded(False) + + self.toast.show_message("All sections collapsed", can_undo=False, can_redo=False) + self._position_toast() + def _button_style(self) -> str: """Get button stylesheet.""" return """ @@ -1204,20 +2245,378 @@ class ProjectDashboard(QWidget): ) def _open_todos(self): - """Open the project's todos.md file.""" - todos_path = self._docs_root / "projects" / self.project.key / "todos.md" + """Open the todos.md file.""" + if self.is_global: + todos_path = self._docs_root / "todos.md" + else: + todos_path = self._docs_root / "projects" / self.project.key / "todos.md" self._open_file_in_editor(todos_path) + def _audit_goals(self): + """Run CmdForge audit-goals tool to check if goals have been met.""" + if self.is_global: + goals_path = self._docs_root / "goals" / "goals.md" + else: + goals_path = self._docs_root / "projects" / self.project.key / "goals.md" + + if not goals_path.exists(): + QMessageBox.warning(self, "No Goals", "No goals.md file found.") + return + + # Confirmation dialog + confirm = QMessageBox.question( + self, + "Run Goals Audit", + "This operation uses AI to analyze your goals against the codebase.\n\n" + "It may take a minute or two to complete.\n\n" + "Do you want to continue?", + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Ok + ) + if confirm != QMessageBox.StandardButton.Ok: + return + + # Read the goals content + goals_content = goals_path.read_text() + + # Get the project root for context + if self.is_global: + project_root = None + else: + project_root = Path(self.project.path) if self.project.path else None + + # Create progress dialog + self._audit_dialog = QDialog(self) + self._audit_dialog.setWindowTitle("Running Goals Audit") + self._audit_dialog.setMinimumWidth(400) + self._audit_dialog.setModal(True) + + layout = QVBoxLayout(self._audit_dialog) + + status_label = QLabel("Analyzing goals with AI...") + status_label.setStyleSheet("font-size: 14px; padding: 10px;") + layout.addWidget(status_label) + + self._audit_time_label = QLabel("Elapsed: 0:00") + self._audit_time_label.setStyleSheet("color: #888; font-size: 12px; padding: 5px;") + layout.addWidget(self._audit_time_label) + + progress = QProgressBar() + progress.setRange(0, 0) # Indeterminate + progress.setStyleSheet(""" + QProgressBar { + border: 1px solid #444; + border-radius: 3px; + background-color: #2d2d2d; + height: 20px; + } + QProgressBar::chunk { + background-color: #4a90d9; + } + """) + layout.addWidget(progress) + + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self._cancel_audit) + layout.addWidget(cancel_btn) + + # Store goals path for later use + self._audit_goals_path = goals_path + + # Create worker and thread + self._audit_thread = QThread() + self._audit_worker = AuditWorker(goals_content, project_root) + self._audit_worker.moveToThread(self._audit_thread) + + # Connect signals + self._audit_thread.started.connect(self._audit_worker.run) + self._audit_worker.finished.connect(self._on_audit_finished) + self._audit_worker.error.connect(self._on_audit_error) + + # Start elapsed time timer + self._audit_elapsed_seconds = 0 + self._audit_timer = QTimer(self) + self._audit_timer.timeout.connect(self._update_audit_time) + self._audit_timer.start(1000) # Update every second + + # Start the thread + self._audit_thread.start() + + # Show the dialog (non-blocking) + self._audit_dialog.show() + + def _update_audit_time(self): + """Update elapsed time display and check for timeout.""" + self._audit_elapsed_seconds += 1 + minutes = self._audit_elapsed_seconds // 60 + seconds = self._audit_elapsed_seconds % 60 + self._audit_time_label.setText(f"Elapsed: {minutes}:{seconds:02d}") + + # Check for 5-minute warning + if self._audit_elapsed_seconds == 300: # 5 minutes + self._show_timeout_warning() + + def _show_timeout_warning(self): + """Show timeout warning after 5 minutes.""" + result = QMessageBox.question( + self._audit_dialog, + "Audit Taking Long", + "The audit has been running for 5 minutes.\n\n" + "Do you want to continue waiting (5 more minutes) or cancel?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes + ) + if result == QMessageBox.StandardButton.No: + self._cancel_audit() + else: + # Reset the timer check for another 5 minutes + self._audit_elapsed_seconds = 0 + + def _cancel_audit(self): + """Cancel the running audit.""" + if self._audit_worker: + self._audit_worker.cancel() + self._cleanup_audit() + if self._audit_dialog: + self._audit_dialog.close() + + def _cleanup_audit(self): + """Clean up audit resources.""" + if self._audit_timer: + self._audit_timer.stop() + self._audit_timer = None + + if self._audit_thread: + self._audit_thread.quit() + self._audit_thread.wait(3000) + self._audit_thread = None + + self._audit_worker = None + + def _on_audit_finished(self, output: str, success: bool): + """Handle audit completion.""" + import json + from datetime import datetime + from PyQt6.QtWidgets import QTextEdit, QDialogButtonBox + + self._cleanup_audit() + + if self._audit_dialog: + self._audit_dialog.close() + self._audit_dialog = None + + goals_path = self._audit_goals_path + + # Try to parse as JSON + try: + # Clean up output - remove any markdown code blocks + json_str = output.strip() + if json_str.startswith("```"): + json_str = json_str.split("```")[1] + if json_str.startswith("json"): + json_str = json_str[4:] + json_str = json_str.strip() + + audit_data = json.loads(json_str) + + # Update the goals.md file with new checkbox states + self._update_goals_from_audit(goals_path, audit_data) + + # Build a human-readable report + report = self._build_audit_report(audit_data) + + # Reload the goals section + self._load_goals() + + except json.JSONDecodeError: + # If JSON parsing fails, just show the raw output + report = f"Could not parse audit response as JSON. Raw output:\n\n{output}" + + # Save report to file + if self.is_global: + report_path = self._docs_root / "goals" / "last_goals_audit.md" + project_key = "global" + dialog_title = "Goals Audit - Global" + else: + report_path = self._docs_root / "projects" / self.project.key / "last_goals_audit.md" + project_key = self.project.key + dialog_title = f"Goals Audit - {self.project.title}" + + report_content = f"""--- +type: audit +project: {project_key} +generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +--- + +# Goals Audit Report + +{report} +""" + atomic_write(report_path, report_content) + + # Show result in a dialog + result_dialog = QDialog(self) + result_dialog.setWindowTitle(dialog_title) + result_dialog.setMinimumSize(700, 500) + + layout = QVBoxLayout(result_dialog) + + # Add note about saved report + saved_label = QLabel(f"Report saved to: {report_path.name}") + saved_label.setStyleSheet("color: #888; font-size: 11px; padding: 5px;") + layout.addWidget(saved_label) + + text_edit = QTextEdit() + text_edit.setReadOnly(True) + text_edit.setPlainText(report) + text_edit.setStyleSheet(""" + QTextEdit { + background-color: #1e1e1e; + color: #e0e0e0; + font-family: monospace; + font-size: 12px; + border: none; + padding: 10px; + } + """) + layout.addWidget(text_edit) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) + buttons.accepted.connect(result_dialog.accept) + layout.addWidget(buttons) + + result_dialog.exec() + + def _on_audit_error(self, error_msg: str): + """Handle audit error.""" + self._cleanup_audit() + + if self._audit_dialog: + self._audit_dialog.close() + self._audit_dialog = None + + QMessageBox.critical(self, "Audit Error", f"Failed to run audit: {error_msg}") + + def _update_goals_from_audit(self, goals_path: Path, audit_data: dict): + """Update goals.md file with checkbox states from audit.""" + from development_hub.parsers.goals_parser import GoalsParser, GoalsSaver + from development_hub.models.goal import Goal, GoalList + + # Parse existing goals to get frontmatter + parser = GoalsParser(goals_path) + frontmatter = parser.frontmatter + + # Build new GoalList from audit data + goal_list = GoalList( + project=frontmatter.get("project"), + updated=frontmatter.get("updated"), + ) + + # Process active goals + for item in audit_data.get("active", []): + goal = Goal( + text=item.get("text", ""), + completed=item.get("checked", False), + priority=item.get("priority", "medium"), + ) + goal_list.active.append(goal) + + # Process future goals + for item in audit_data.get("future", []): + goal = Goal( + text=item.get("text", ""), + completed=item.get("checked", False), + priority=item.get("priority", "medium"), + ) + goal_list.future.append(goal) + + # Process non-goals + for item in audit_data.get("non_goals", []): + goal = Goal( + text=item.get("text", ""), + completed=item.get("checked", False), + priority=item.get("priority", "medium"), + ) + goal_list.non_goals.append(goal) + + # Save updated goals + saver = GoalsSaver(goals_path, frontmatter) + saver.save(goal_list) + + def _build_audit_report(self, audit_data: dict) -> str: + """Build a human-readable report from audit data.""" + lines = [] + lines.append("=" * 60) + lines.append("GOALS AUDIT REPORT") + lines.append("=" * 60) + lines.append("") + + # Active goals + lines.append("ACTIVE GOALS") + lines.append("-" * 40) + for item in audit_data.get("active", []): + status = "[x]" if item.get("checked") else "[ ]" + lines.append(f"{status} {item.get('text', '')}") + lines.append(f" -> {item.get('reason', 'No reason provided')}") + lines.append("") + + # Future goals + lines.append("FUTURE GOALS") + lines.append("-" * 40) + for item in audit_data.get("future", []): + status = "[x]" if item.get("checked") else "[ ]" + lines.append(f"{status} {item.get('text', '')}") + lines.append(f" -> {item.get('reason', 'No reason provided')}") + lines.append("") + + # Non-goals + lines.append("NON-GOALS") + lines.append("-" * 40) + for item in audit_data.get("non_goals", []): + status = "[x]" if item.get("checked") else "[ ]" + lines.append(f"{status} {item.get('text', '')}") + lines.append(f" -> {item.get('reason', 'No reason provided')}") + lines.append("") + + # Summary + summary = audit_data.get("summary", {}) + lines.append("=" * 60) + lines.append("SUMMARY") + lines.append("=" * 60) + lines.append(f"Active Goals: {summary.get('active_met', 0)}/{summary.get('active_total', 0)} met") + lines.append(f"Future Goals: {summary.get('future_started', 0)}/{summary.get('future_total', 0)} started") + lines.append(f"Non-Goals: {summary.get('non_goals_confirmed', 0)}/{summary.get('non_goals_total', 0)} confirmed") + lines.append("") + lines.append(f"Overall Health: {summary.get('health', 'UNKNOWN')}") + lines.append("") + lines.append("Goals file has been updated with the audit results.") + + return "\n".join(lines) + def _open_goals(self): - """Open the project's goals.md file.""" - goals_path = self._docs_root / "projects" / self.project.key / "goals.md" + """Open the goals.md file.""" + if self.is_global: + goals_path = self._docs_root / "goals" / "goals.md" + else: + goals_path = self._docs_root / "projects" / self.project.key / "goals.md" self._open_file_in_editor(goals_path) def _open_milestones(self): - """Open the project's milestones.md file.""" - milestones_path = self._docs_root / "projects" / self.project.key / "milestones.md" + """Open the milestones.md file.""" + if self.is_global: + milestones_path = self._docs_root / "goals" / "milestones.md" + else: + milestones_path = self._docs_root / "projects" / self.project.key / "milestones.md" self._open_file_in_editor(milestones_path) + def _open_ideas(self): + """Open the ideas-and-exploration.md file.""" + if self.is_global: + ideas_path = self._docs_root / "goals" / "ideas-and-exploration.md" + else: + ideas_path = self._docs_root / "projects" / self.project.key / "ideas-and-exploration.md" + self._open_file_in_editor(ideas_path) + def _wrap_layout(self, layout: QHBoxLayout) -> QWidget: """Wrap a layout in a widget.""" widget = QWidget() @@ -1232,6 +2631,12 @@ class ProjectDashboard(QWidget): if not text: return + # Cannot add goals in global mode (no project context) + if self.is_global: + self.toast.show_message("Cannot add goals in global view") + self._position_toast() + return + goals_path = self._docs_root / "projects" / self.project.key / "goals.md" # Create file if needed @@ -1255,28 +2660,124 @@ class ProjectDashboard(QWidget): self._goals_parser = GoalsParser(goals_path) self._goal_list = self._goals_parser.parse() - # Add the goal - new_goal = Goal(text=text, completed=False, priority="high") - self._goal_list.active.append(new_goal) + # Get section and priority from dropdowns + section = self.goal_section_combo.currentData() + priority = self.goal_priority_combo.currentData() + + new_goal = Goal(text=text, completed=False, priority=priority) + if section == "non-goal": + self._goal_list.non_goals.append(new_goal) + elif section == "future": + self._goal_list.future.append(new_goal) + else: # active + self._goal_list.active.append(new_goal) # Save self._save_goals() self.goal_input.clear() self._load_goals() - def _on_goal_toggled(self, goal, completed): - """Handle goal checkbox toggle.""" + def _find_goal_section(self, goal) -> str: + """Find which section a goal belongs to.""" + if self._goal_list: + for g in self._goal_list.active: + if g.text == goal.text: + return "active" + for g in self._goal_list.future: + if g.text == goal.text: + return "future" + for g in self._goal_list.non_goals: + if g.text == goal.text: + return "non_goals" + return "active" # default + + def _on_goal_toggled(self, goal, completed, was_completed, was_partial): + """Handle goal checkbox toggle. + + Args: + goal: The goal object (already updated with new state) + completed: New completed state + was_completed: Previous completed state + was_partial: Previous partial state + """ if self._goals_parser and self._goal_list: + section = self._find_goal_section(goal) + # Store for undo: previous state before toggle (now passed correctly from widget) + self._undo_stack.append(("goal_toggle", goal.text, goal.priority, was_completed, was_partial, section)) + self._redo_stack.clear() + self._save_goals() self._load_goals() + # Describe the state change + if completed: + action = "achieved" + elif getattr(goal, 'partial', False): + action = "marked partial" + else: + action = "marked not achieved" + truncated = goal.text[:30] + "..." if len(goal.text) > 30 else goal.text + self.toast.show_message( + f"Goal {action}: {truncated}", + can_undo=bool(self._undo_stack), + can_redo=bool(self._redo_stack), + ) + self._position_toast() + def _on_goal_deleted(self, goal): """Handle goal deletion.""" if self._goal_list: + section = self._find_goal_section(goal) + # Store for undo + self._undo_stack.append(("goal_delete", goal.text, goal.priority, goal.completed, getattr(goal, 'partial', False), section)) + self._redo_stack.clear() + + # Remove from all sections (active, future, non_goals) self._goal_list.active = [g for g in self._goal_list.active if g.text != goal.text] + self._goal_list.future = [g for g in self._goal_list.future if g.text != goal.text] + self._goal_list.non_goals = [g for g in self._goal_list.non_goals if g.text != goal.text] self._save_goals() self._load_goals() + truncated = goal.text[:30] + "..." if len(goal.text) > 30 else goal.text + self.toast.show_message( + f"Deleted goal: {truncated}", + can_undo=bool(self._undo_stack), + can_redo=bool(self._redo_stack), + ) + self._position_toast() + + def _on_goal_edited(self, goal, old_text: str, new_text: str): + """Handle goal text edited inline.""" + if self._goal_list and self._goals_parser: + section = self._find_goal_section(goal) + # Store for undo + self._undo_stack.append(("goal_edit", old_text, new_text, goal.priority, goal.completed, getattr(goal, 'partial', False), section)) + self._redo_stack.clear() + + # Update the goal in all sections (active, future, non_goals) + for goal_list in [self._goal_list.active, self._goal_list.future, self._goal_list.non_goals]: + for g in goal_list: + if g.text == old_text: + g.text = new_text + break + + # Also update the passed goal object + goal.text = new_text + + self._save_goals() + self._load_goals() + + # Show toast notification + truncated_old = old_text[:20] + "..." if len(old_text) > 20 else old_text + truncated_new = new_text[:20] + "..." if len(new_text) > 20 else new_text + self.toast.show_message( + f"Goal edited: {truncated_old} → {truncated_new}", + can_undo=bool(self._undo_stack), + can_redo=bool(self._redo_stack), + ) + self._position_toast() + def _save_goals(self): """Save goals to file.""" if self._goals_parser and self._goal_list: @@ -1284,6 +2785,146 @@ class ProjectDashboard(QWidget): saver = GoalsSaver(self._goals_path, self._goals_parser.frontmatter) saver.save(self._goal_list) + # === Idea handlers === + + def _add_idea(self): + """Add a new idea from the input field.""" + text = self.idea_input.text().strip() + if not text: + return + + # Cannot add ideas in global mode (no project context) + if self.is_global: + self.toast.show_message("Cannot add ideas in global view") + self._position_toast() + return + + ideas_path = self._docs_root / "projects" / self.project.key / "ideas-and-exploration.md" + + # Create file if needed + if not ideas_path.exists(): + ideas_path.parent.mkdir(parents=True, exist_ok=True) + ideas_path.write_text( + "---\n" + f"type: ideas\n" + f"project: {self.project.key}\n" + f"updated: 2026-01-08\n" + "public: True\n" + "---\n\n" + "# Ideas & Exploration\n\n" + "## Ideas\n\n" + ) + self._ideas_list = [] + + if not hasattr(self, '_ideas_list') or self._ideas_list is None: + self._ideas_list = [] + + # Add the idea with selected priority + priority = self.idea_priority_combo.currentData() + new_idea = Goal(text=text, completed=False, priority=priority) + self._ideas_list.append(new_idea) + + # Save + self._save_ideas() + self.idea_input.clear() + self._load_ideas() + + def _on_idea_toggled(self, idea, completed): + """Handle idea checkbox toggle.""" + # Store for undo: previous state before toggle + was_completed = not completed + self._undo_stack.append(("idea_toggle", idea.text, idea.priority, was_completed)) + self._redo_stack.clear() + + self._save_ideas() + self._load_ideas() + + action = "completed" if completed else "uncompleted" + truncated = idea.text[:30] + "..." if len(idea.text) > 30 else idea.text + self.toast.show_message( + f"Idea {action}: {truncated}", + can_undo=bool(self._undo_stack), + can_redo=bool(self._redo_stack), + ) + self._position_toast() + + def _on_idea_deleted(self, idea): + """Handle idea deletion.""" + if hasattr(self, '_ideas_list') and self._ideas_list: + # Store for undo + self._undo_stack.append(("idea_delete", idea.text, idea.priority, idea.completed)) + self._redo_stack.clear() + + self._ideas_list = [i for i in self._ideas_list if i.text != idea.text] + self._save_ideas() + self._load_ideas() + + truncated = idea.text[:30] + "..." if len(idea.text) > 30 else idea.text + self.toast.show_message( + f"Deleted idea: {truncated}", + can_undo=bool(self._undo_stack), + can_redo=bool(self._redo_stack), + ) + self._position_toast() + + def _on_idea_edited(self, idea, old_text: str, new_text: str): + """Handle idea text edited inline.""" + if hasattr(self, '_ideas_list') and self._ideas_list: + # Store for undo + self._undo_stack.append(("idea_edit", old_text, new_text, idea.priority, idea.completed)) + self._redo_stack.clear() + + # Update the idea in the list + for i in self._ideas_list: + if i.text == old_text: + i.text = new_text + break + + # Also update the passed idea object + idea.text = new_text + + self._save_ideas() + self._load_ideas() + + truncated_old = old_text[:20] + "..." if len(old_text) > 20 else old_text + truncated_new = new_text[:20] + "..." if len(new_text) > 20 else new_text + self.toast.show_message( + f"Idea edited: {truncated_old} → {truncated_new}", + can_undo=bool(self._undo_stack), + can_redo=bool(self._redo_stack), + ) + self._position_toast() + + def _save_ideas(self): + """Save ideas to file.""" + if not hasattr(self, '_ideas_path') or not hasattr(self, '_ideas_list'): + return + + # Cannot save ideas in global mode (no project context) + if self.is_global: + return + + lines = [] + lines.append("---") + lines.append("type: ideas") + lines.append(f"project: {self.project.key}") + lines.append("updated: 2026-01-08") + lines.append("public: True") + lines.append("---") + lines.append("") + lines.append("# Ideas & Exploration") + lines.append("") + lines.append("## Ideas") + lines.append("") + + for idea in self._ideas_list: + checkbox = "[x]" if idea.completed else "[ ]" + priority_tag = f" #{idea.priority}" if idea.priority else "" + lines.append(f"- {checkbox} {idea.text}{priority_tag}") + + lines.append("") + atomic_write(self._ideas_path, "\n".join(lines)) + # === Milestone handlers === def _add_milestone(self): @@ -1292,6 +2933,12 @@ class ProjectDashboard(QWidget): if not text: return + # Cannot add milestones in global mode (no project context) + if self.is_global: + self.toast.show_message("Cannot add milestones in global view") + self._position_toast() + return + milestones_path = self._docs_root / "projects" / self.project.key / "milestones.md" # Create file if needed @@ -1379,38 +3026,57 @@ class ProjectDashboard(QWidget): if self._milestones_parser and self._milestones_list: self._milestones_parser.save(self._milestones_list) + def _sync_milestone_status_from_todos(self): + """Update milestone status based on linked todos completion.""" + if not self._milestones_list or not self._todo_list: + return + + from development_hub.models.goal import MilestoneStatus + + changed = False + for milestone in self._milestones_list: + linked_todos = self._todo_list.get_by_milestone(milestone.id) + if not linked_todos: + continue + + total = len(linked_todos) + done = sum(1 for t in linked_todos if t.completed) + + if done == total: + if milestone.status != MilestoneStatus.COMPLETE: + milestone.status = MilestoneStatus.COMPLETE + milestone.progress = 100 + changed = True + elif done > 0: + if milestone.status not in (MilestoneStatus.IN_PROGRESS, MilestoneStatus.COMPLETE): + milestone.status = MilestoneStatus.IN_PROGRESS + progress = int((done / total) * 100) + if milestone.progress != progress: + milestone.progress = progress + changed = True + else: + # No todos done - only downgrade if currently complete + if milestone.status == MilestoneStatus.COMPLETE: + milestone.status = MilestoneStatus.IN_PROGRESS + milestone.progress = 0 + changed = True + + if changed: + self._save_milestones() + def _on_milestone_todo_toggled(self, todo, completed): """Handle todo toggle from within milestone widget. - This saves the todo and reloads the view. + Delegates to _on_todo_toggled for consistent behavior and undo support. """ - if self._todos_parser and self._todo_list: - # Update the todo in self._todo_list (milestone has different instance) - for t in self._todo_list.all_todos: - if t.text == todo.text and t.priority == todo.priority: - t.completed = completed - break - - self._save_todos() - self._load_todos() - self._load_milestones() # Sync milestone view - # Show toast - action = "completed" if completed else "uncompleted" - truncated = todo.text[:30] + "..." if len(todo.text) > 30 else todo.text - self.toast.show_message(f"Todo {action}: {truncated}") - self._position_toast() + self._on_todo_toggled(todo, completed) def _on_milestone_todo_deleted(self, todo): - """Handle todo deletion from within milestone widget.""" - if self._todo_list and self._todos_parser: - self._todo_list.remove_todo(todo) - self._save_todos() - self._load_todos() - self._load_milestones() # Refresh milestone views - # Show toast - truncated = todo.text[:30] + "..." if len(todo.text) > 30 else todo.text - self.toast.show_message(f"Deleted: {truncated}") - self._position_toast() + """Handle todo deletion from within milestone widget. + + Delegates to _on_todo_deleted for consistent behavior and undo support. + """ + self._on_todo_deleted(todo) def _on_milestone_todo_added(self, text, priority, milestone_id): """Handle adding a new todo from within a milestone widget. @@ -1420,6 +3086,12 @@ class ProjectDashboard(QWidget): priority: Priority level (high, medium, low) milestone_id: Milestone ID to tag (e.g., "M1") """ + # Cannot add todos in global mode (no project context) + if self.is_global: + self.toast.show_message("Cannot add todos in global view") + self._position_toast() + return + # Ensure parser exists todos_path = self._docs_root / "projects" / self.project.key / "todos.md" if not self._todos_parser: @@ -1443,3 +3115,56 @@ class ProjectDashboard(QWidget): truncated = text[:30] + "..." if len(text) > 30 else text self.toast.show_message(f"Added to {milestone_id}: {truncated}") self._position_toast() + + # === Session state persistence === + + def get_state(self) -> dict: + """Get the dashboard state for session persistence. + + Returns: + Dict with filter selection and section expanded states + """ + return { + "filter": self.todo_filter.currentIndex(), + "sections": { + "goals_active": self.goals_active_section._expanded, + "goals_future": self.goals_future_section._expanded, + "goals_nongoals": self.goals_nongoals_section._expanded, + "ideas": self.ideas_section._expanded, + "activity": self.activity_section._expanded, + "blocked": self.blocked_section._expanded, + "high": self.high_section._expanded, + "medium": self.medium_section._expanded, + "low": self.low_section._expanded, + "completed": self.completed_section._expanded, + }, + } + + def restore_state(self, state: dict): + """Restore the dashboard state from session. + + Args: + state: Dict with filter and sections keys + """ + # Restore filter selection + filter_index = state.get("filter", 0) + if 0 <= filter_index < self.todo_filter.count(): + self.todo_filter.setCurrentIndex(filter_index) + + # Restore section expanded states + sections_state = state.get("sections", {}) + section_map = { + "goals_active": self.goals_active_section, + "goals_future": self.goals_future_section, + "goals_nongoals": self.goals_nongoals_section, + "ideas": self.ideas_section, + "activity": self.activity_section, + "blocked": self.blocked_section, + "high": self.high_section, + "medium": self.medium_section, + "low": self.low_section, + "completed": self.completed_section, + } + for key, section in section_map.items(): + if key in sections_state: + section.set_expanded(sections_state[key]) diff --git a/src/development_hub/views/global_dashboard.py b/src/development_hub/views/global_dashboard.py index b474edc..406606e 100644 --- a/src/development_hub/views/global_dashboard.py +++ b/src/development_hub/views/global_dashboard.py @@ -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 = "
".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("
".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() diff --git a/src/development_hub/widgets/collapsible_section.py b/src/development_hub/widgets/collapsible_section.py index 1546966..91cc635 100644 --- a/src/development_hub/widgets/collapsible_section.py +++ b/src/development_hub/widgets/collapsible_section.py @@ -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 diff --git a/src/development_hub/widgets/health_card.py b/src/development_hub/widgets/health_card.py index 4cc0905..606578a 100644 --- a/src/development_hub/widgets/health_card.py +++ b/src/development_hub/widgets/health_card.py @@ -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: diff --git a/src/development_hub/workspace.py b/src/development_hub/workspace.py index 3533388..9ee5550 100644 --- a/src/development_hub/workspace.py +++ b/src/development_hub/workspace.py @@ -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) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_parser_roundtrip.py b/tests/test_parser_roundtrip.py new file mode 100644 index 0000000..371a613 --- /dev/null +++ b/tests/test_parser_roundtrip.py @@ -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"])