fix: Dashboard bug fixes and improvements

Critical bug fixes:
- Fix edit undo crash (5-tuple vs 4-tuple handling)
- Fix milestone tag lost on undo (store todo.milestone in undo data)
- Fix global mode null safety (guards for 8 functions)
- Fix ideas priority not saved/loaded
- Fix goal deletion for all sections (active/future/non_goals)
- Fix goal partial-state undo/redo (widget now emits previous state)
- Fix non-goals without checkboxes being dropped on save

New features:
- Add undo/redo support for goals and ideas (toggle, delete, edit)
- Add atomic_write() for non-destructive file saves
- Add round-trip tests for parsers

Improvements:
- Consolidate duplicated milestone todo handlers (delegate to main handlers)
- Milestones now save with Active/Completed sections
- Global dashboard shows milestone completion ratio

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-08 20:13:59 -04:00
parent 5c45f7e3f0
commit 69ec7df308
13 changed files with 2870 additions and 534 deletions

View File

@ -55,6 +55,7 @@ class Milestone:
progress: int = 0 # 0-100 progress: int = 0 # 0-100
deliverables: list[Deliverable] = field(default_factory=list) deliverables: list[Deliverable] = field(default_factory=list)
notes: str = "" notes: str = ""
description: str = "" # Free-form description text
@property @property
def is_complete(self) -> bool: def is_complete(self) -> bool:
@ -80,9 +81,16 @@ class Milestone:
@dataclass @dataclass
class Goal: 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 text: str
completed: bool = False completed: bool = False
partial: bool = False # Partially achieved (orange with half-moon)
priority: str = "medium" # "high", "medium", "low" priority: str = "medium" # "high", "medium", "low"
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
project: str | None = None project: str | None = None
@ -99,7 +107,7 @@ class GoalList:
"""Collection of goals organized by status.""" """Collection of goals organized by status."""
active: list[Goal] = field(default_factory=list) active: list[Goal] = field(default_factory=list)
future: 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 project: str | None = None
updated: str | None = None updated: str | None = None

View File

@ -43,6 +43,9 @@ class Todo:
parts.append(f"@{self.project}") parts.append(f"@{self.project}")
for tag in self.tags: for tag in self.tags:
parts.append(f"#{tag}") 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: if self.completed_date:
parts.append(f"({self.completed_date})") parts.append(f"({self.completed_date})")
if self.blocker_reason: if self.blocker_reason:

View File

@ -1,10 +1,45 @@
"""Base parser class with common utilities.""" """Base parser class with common utilities."""
import os
import re import re
import tempfile
from pathlib import Path from pathlib import Path
from typing import Any 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]: def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
"""Parse YAML frontmatter from markdown content. """Parse YAML frontmatter from markdown content.
@ -127,6 +162,29 @@ class BaseParser:
return checked, text return checked, text
return False, line.strip() 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 @staticmethod
def extract_milestone_tag(text: str) -> tuple[str | None, str]: def extract_milestone_tag(text: str) -> tuple[str | None, str]:
"""Extract @M1, @M2, etc. milestone tag from text. """Extract @M1, @M2, etc. milestone tag from text.

View File

@ -3,7 +3,7 @@
import re import re
from pathlib import Path 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 ( from development_hub.models.goal import (
Goal, Goal,
GoalList, GoalList,
@ -63,27 +63,35 @@ class GoalsParser(BaseParser):
current_section = "non-goals" current_section = "non-goals"
continue continue
# Handle checkbox items (Active and Future) # Handle checkbox items (Active, Future, and Non-Goals)
if line_stripped.startswith("- [") and current_section in ("active", "future"): if line_stripped.startswith("- ["):
goal = self._parse_goal_line(line_stripped) goal = self._parse_goal_line(line_stripped)
if goal: if goal:
if current_section == "active": if current_section == "active":
goal_list.active.append(goal) goal_list.active.append(goal)
else: elif current_section == "future":
goal_list.future.append(goal) goal_list.future.append(goal)
continue else: # non-goals
goal_list.non_goals.append(goal)
# Handle non-goals (simple bullets) # Handle plain bullet items in non-goals section (backwards compatibility)
if current_section == "non-goals" and line_stripped.startswith("- "): elif current_section == "non-goals" and line_stripped.startswith("- "):
text = line_stripped[2:].strip() text = line_stripped[2:].strip()
if text: 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 return goal_list
def _parse_goal_line(self, line: str) -> Goal | None: def _parse_goal_line(self, line: str) -> Goal | None:
"""Parse a single goal line.""" """Parse a single goal line with three states."""
completed, text = self.parse_checkbox(line) completed, partial, text = self.parse_checkbox_triple(line)
if not text: if not text:
return None return None
@ -103,6 +111,7 @@ class GoalsParser(BaseParser):
return Goal( return Goal(
text=text, text=text,
completed=completed, completed=completed,
partial=partial,
priority=priority, priority=priority,
tags=tags, tags=tags,
completed_date=date, completed_date=date,
@ -115,14 +124,18 @@ class MilestonesParser(BaseParser):
def parse(self) -> list[Milestone]: def parse(self) -> list[Milestone]:
"""Parse milestones.md file. """Parse milestones.md file.
Expected format: Expected format (flat list, no section headers):
#### M1: Milestone Name #### M1: Milestone Name
**Target**: January 2026 **Target**: January 2026
**Status**: In Progress (80%) **Status**: In Progress (80%)
| Deliverable | Status | Description text here...
|-------------|--------|
| Item 1 | Done | ---
#### M2: Another Milestone
**Target**: February 2026
**Status**: Completed (100%)
Returns: Returns:
List of Milestone instances List of Milestone instances
@ -132,32 +145,50 @@ class MilestonesParser(BaseParser):
if not self.body: if not self.body:
return milestones return milestones
# Split by milestone headers (#### M#:) milestone_pattern = r"####\s+(M[\d.]+):\s*(.+)"
milestone_pattern = r"####\s+(M\d+):\s*(.+)"
parts = re.split(f"({milestone_pattern})", self.body)
i = 0 lines = self.body.split("\n")
while i < len(parts): current_milestone_lines = []
part = parts[i] current_milestone_id = None
current_milestone_name = None
# Check for milestone header match for line in lines:
match = re.match(milestone_pattern, part) 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: if match:
milestone_id = match.group(1) # Save previous milestone if exists
milestone_name = match.group(2).strip() 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 # Start new milestone
content = "" current_milestone_id = match.group(1)
if i + 1 < len(parts): current_milestone_name = match.group(2).strip()
content = parts[i + 1] current_milestone_lines = []
elif current_milestone_id:
# Accumulate lines for current milestone
current_milestone_lines.append(line)
milestone = self._parse_milestone_content( # Don't forget the last milestone
milestone_id, milestone_name, content if current_milestone_id:
) milestone = self._parse_milestone_content(
if milestone: current_milestone_id,
milestones.append(milestone) current_milestone_name,
"\n".join(current_milestone_lines),
i += 1 )
if milestone:
milestones.append(milestone)
return milestones return milestones
@ -179,6 +210,7 @@ class MilestonesParser(BaseParser):
progress = 0 progress = 0
deliverables = [] deliverables = []
notes = "" notes = ""
description_lines = []
lines = content.split("\n") lines = content.split("\n")
table_lines = [] table_lines = []
@ -187,6 +219,10 @@ class MilestonesParser(BaseParser):
for line in lines: for line in lines:
line_stripped = line.strip() line_stripped = line.strip()
# Skip separators
if line_stripped == "---":
continue
# Parse **Target**: value # Parse **Target**: value
target_match = re.match(r"\*\*Target\*\*:\s*(.+)", line_stripped) target_match = re.match(r"\*\*Target\*\*:\s*(.+)", line_stripped)
if target_match: if target_match:
@ -210,15 +246,16 @@ class MilestonesParser(BaseParser):
if line_stripped.startswith("|"): if line_stripped.startswith("|"):
in_table = True in_table = True
table_lines.append(line_stripped) table_lines.append(line_stripped)
continue
elif in_table and not line_stripped.startswith("|"): elif in_table and not line_stripped.startswith("|"):
# Table ended # Table ended
deliverables = self._parse_deliverables_table(table_lines) deliverables = self._parse_deliverables_table(table_lines)
table_lines = [] table_lines = []
in_table = False in_table = False
# Stop at next section marker # Collect description lines (non-empty, non-field lines)
if line_stripped.startswith("---") or line_stripped.startswith("## "): if line_stripped and not in_table:
break description_lines.append(line_stripped)
# Handle any remaining table # Handle any remaining table
if table_lines: if table_lines:
@ -232,6 +269,7 @@ class MilestonesParser(BaseParser):
progress=progress, progress=progress,
deliverables=deliverables, deliverables=deliverables,
notes=notes, notes=notes,
description=" ".join(description_lines),
) )
def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]: def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]:
@ -323,21 +361,18 @@ class MilestonesParser(BaseParser):
# Write active milestones # Write active milestones
lines.append("## Active") lines.append("## Active")
lines.append("") lines.append("")
for milestone in active: for milestone in active:
lines.extend(self._format_milestone(milestone)) lines.extend(self._format_milestone(milestone))
lines.append("") lines.append("")
# Write completed milestones # 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("") lines.append("")
for milestone in completed: atomic_write(self.file_path, "\n".join(lines))
lines.extend(self._format_milestone(milestone))
lines.append("")
self.path.write_text("\n".join(lines))
def _format_milestone(self, milestone: Milestone) -> list[str]: def _format_milestone(self, milestone: Milestone) -> list[str]:
"""Format a single milestone as markdown lines. """Format a single milestone as markdown lines.
@ -360,7 +395,7 @@ class MilestonesParser(BaseParser):
# Status with progress # Status with progress
progress = milestone.calculate_progress() progress = milestone.calculate_progress()
if milestone.status == MilestoneStatus.COMPLETE: if milestone.status == MilestoneStatus.COMPLETE:
lines.append("**Status**: Complete") lines.append(f"**Status**: Completed ({progress}%)")
elif milestone.status == MilestoneStatus.IN_PROGRESS: elif milestone.status == MilestoneStatus.IN_PROGRESS:
lines.append(f"**Status**: In Progress ({progress}%)") lines.append(f"**Status**: In Progress ({progress}%)")
elif milestone.status == MilestoneStatus.PLANNING: elif milestone.status == MilestoneStatus.PLANNING:
@ -372,10 +407,14 @@ class MilestonesParser(BaseParser):
if milestone.notes: if milestone.notes:
lines.append(f"**Notes**: {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 # Deliverables table
if milestone.deliverables: if milestone.deliverables:
lines.append("")
lines.append("| Deliverable | Status |") lines.append("| Deliverable | Status |")
lines.append("|-------------|--------|") lines.append("|-------------|--------|")
@ -422,7 +461,7 @@ class GoalsSaver:
lines.append("## Active") lines.append("## Active")
lines.append("") lines.append("")
for goal in goal_list.active: 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 "" priority_tag = f" #{goal.priority}" if goal.priority else ""
lines.append(f"- {checkbox} {goal.text}{priority_tag}") lines.append(f"- {checkbox} {goal.text}{priority_tag}")
lines.append("") lines.append("")
@ -432,16 +471,36 @@ class GoalsSaver:
lines.append("## Future") lines.append("## Future")
lines.append("") lines.append("")
for goal in goal_list.future: for goal in goal_list.future:
checkbox = "[x]" if goal.completed else "[ ]" checkbox = self._get_checkbox(goal)
lines.append(f"- {checkbox} {goal.text}") priority_tag = f" #{goal.priority}" if goal.priority else ""
lines.append(f"- {checkbox} {goal.text}{priority_tag}")
lines.append("") lines.append("")
# Non-goals # Non-goals (also with checkboxes and priority)
if goal_list.non_goals: if goal_list.non_goals:
lines.append("## Non-Goals") lines.append("## Non-Goals")
lines.append("") lines.append("")
for non_goal in goal_list.non_goals: for goal in goal_list.non_goals:
lines.append(f"- {non_goal}") checkbox = self._get_checkbox(goal)
priority_tag = f" #{goal.priority}" if goal.priority else ""
lines.append(f"- {checkbox} {goal.text}{priority_tag}")
lines.append("") 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 "[ ]"

View File

@ -3,7 +3,7 @@
import re import re
from pathlib import Path 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 from development_hub.models.todo import Todo, TodoList
@ -134,10 +134,23 @@ class TodosParser(BaseParser):
if section == "completed": if section == "completed":
completed = True 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( todo = Todo(
text=text, text=text,
completed=completed, completed=completed,
priority=priority if not completed else "completed", priority=effective_priority,
project=project, project=project,
milestone=milestone, milestone=milestone,
tags=tags, tags=tags,
@ -262,4 +275,4 @@ class TodosParser(BaseParser):
todo_list: TodoList to save todo_list: TodoList to save
""" """
content = self.write(todo_list) content = self.write(todo_list)
self.file_path.write_text(content) atomic_write(self.file_path, content)

View File

@ -193,6 +193,13 @@ class ProjectListWidget(QWidget):
progress_bar.setObjectName("progress_bar") progress_bar.setObjectName("progress_bar")
layout.addWidget(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 return dialog
def _deploy_docs(self, project: Project): def _deploy_docs(self, project: Project):
@ -350,6 +357,11 @@ class ProjectListWidget(QWidget):
"Deploy Complete" if success else "Deploy Failed" "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 self._deploy_thread = None
def _rebuild_main_docs(self): def _rebuild_main_docs(self):
@ -394,6 +406,11 @@ class ProjectListWidget(QWidget):
"Rebuild Complete" if success else "Rebuild Failed" "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 self._rebuild_thread = None
def _update_docs(self, project: Project): def _update_docs(self, project: Project):

File diff suppressed because it is too large Load Diff

View File

@ -1,284 +1,36 @@
"""Global dashboard view showing cross-project status.""" """Global dashboard view - wrapper around ProjectDashboard in global mode."""
import subprocess from PyQt6.QtCore import pyqtSignal
import shutil from PyQt6.QtWidgets import QWidget, QVBoxLayout
from pathlib import Path
from PyQt6.QtCore import Qt, pyqtSignal from development_hub.views.dashboard import ProjectDashboard
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
class GlobalDashboard(QWidget): class GlobalDashboard(QWidget):
"""Global dashboard showing status across all projects. """Global dashboard showing status across all projects.
Displays: This is a thin wrapper around ProjectDashboard in global mode.
- Current milestone progress (global)
- Project health summary
- Projects needing attention
- Today's progress
""" """
project_selected = pyqtSignal(str) # Emits project_key project_selected = pyqtSignal(str) # Emits project_key
standup_requested = pyqtSignal() standup_requested = pyqtSignal()
def __init__(self, parent: QWidget | None = None): def __init__(self, parent: QWidget | None = None):
"""Initialize global dashboard. """Initialize global dashboard."""
Args:
parent: Parent widget
"""
super().__init__(parent) super().__init__(parent)
self._docs_root = Path.home() / "PycharmProjects" / "project-docs" / "docs"
self._setup_ui()
self._load_data()
def _setup_ui(self): # Create ProjectDashboard in global mode
"""Set up the UI.""" self._dashboard = ProjectDashboard(project=None, parent=self)
# Main scroll area
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameStyle(QFrame.Shape.NoFrame)
scroll.setStyleSheet("QScrollArea { background-color: #1e1e1e; border: none; }")
# Content widget # Forward signals
content = QWidget() self._dashboard.project_selected.connect(self.project_selected.emit)
content.setStyleSheet("background-color: #1e1e1e;") self._dashboard.standup_requested.connect(self.standup_requested.emit)
layout = QVBoxLayout(content)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(20)
# Header # Layout
header_layout = QHBoxLayout() layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
title = QLabel("Development Hub") layout.addWidget(self._dashboard)
title.setStyleSheet("font-size: 24px; font-weight: bold; color: #e0e0e0;")
header_layout.addWidget(title)
header_layout.addStretch()
# Daily standup button
standup_btn = QPushButton("Daily Standup")
standup_btn.setStyleSheet("""
QPushButton {
background-color: #3d6a99;
color: #e0e0e0;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 13px;
}
QPushButton:hover {
background-color: #4a7ab0;
}
""")
standup_btn.clicked.connect(self.standup_requested.emit)
header_layout.addWidget(standup_btn)
layout.addLayout(header_layout)
# Milestone progress
milestone_section = QLabel("CURRENT MILESTONE")
milestone_section.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888;")
layout.addWidget(milestone_section)
self.milestone_progress = MilestoneProgressBar()
layout.addWidget(self.milestone_progress)
# Stats row
self.stats_row = StatCardRow()
self.stats_row.add_stat("healthy", 0, "Healthy", "#4caf50")
self.stats_row.add_stat("warning", 0, "Warning", "#ff9800")
self.stats_row.add_stat("total_todos", 0, "Total TODOs", "#e0e0e0")
layout.addWidget(self.stats_row)
# Action buttons
actions_layout = QHBoxLayout()
actions_layout.setSpacing(8)
view_todos_btn = QPushButton("View Global TODOs")
view_todos_btn.setStyleSheet(self._button_style())
view_todos_btn.clicked.connect(self._open_todos)
actions_layout.addWidget(view_todos_btn)
view_milestones_btn = QPushButton("View Milestones")
view_milestones_btn.setStyleSheet(self._button_style())
view_milestones_btn.clicked.connect(self._open_milestones)
actions_layout.addWidget(view_milestones_btn)
actions_layout.addStretch()
layout.addLayout(actions_layout)
# Project health section
health_label = QLabel("PROJECT HEALTH")
health_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
layout.addWidget(health_label)
self.health_container = QVBoxLayout()
self.health_container.setSpacing(4)
layout.addLayout(self.health_container)
# Needs attention section
attention_label = QLabel("NEEDS ATTENTION")
attention_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
layout.addWidget(attention_label)
self.attention_list = QLabel()
self.attention_list.setStyleSheet("color: #ff9800; line-height: 1.6;")
self.attention_list.setWordWrap(True)
layout.addWidget(self.attention_list)
# Today's progress section
progress_label = QLabel("TODAY'S PROGRESS")
progress_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
layout.addWidget(progress_label)
self.progress_list = QLabel()
self.progress_list.setStyleSheet("color: #b0b0b0; line-height: 1.6;")
self.progress_list.setWordWrap(True)
layout.addWidget(self.progress_list)
layout.addStretch()
scroll.setWidget(content)
# Main layout
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(scroll)
def _load_data(self):
"""Load data and update display."""
# Get ecosystem health
checker = HealthChecker()
ecosystem = checker.check_all_projects()
# Update stats
self.stats_row.update_stat("healthy", ecosystem.healthy_count)
self.stats_row.update_stat("warning", ecosystem.warning_count + ecosystem.critical_count)
self.stats_row.update_stat("total_todos", ecosystem.total_active_todos)
# Load milestone
milestones_path = self._docs_root / "goals" / "milestones.md"
if milestones_path.exists():
parser = MilestonesParser(milestones_path)
current = parser.get_current_milestone()
if current:
self.milestone_progress.set_milestone(
f"{current.id}: {current.name}",
current.progress,
current.target,
)
else:
self.milestone_progress.clear()
else:
self.milestone_progress.clear()
# Clear and rebuild health cards
while self.health_container.count():
item = self.health_container.takeAt(0)
if item.widget():
item.widget().deleteLater()
for health in ecosystem.projects:
card = HealthCardCompact()
card.set_project(
health.project_key,
health.project_name,
health.status_icon,
health.git_info.time_since_commit,
health.active_todos,
)
card.clicked.connect(self._on_project_clicked)
self.health_container.addWidget(card)
# Needs attention
needing = ecosystem.get_needing_attention()
if needing:
attention_html = "<br>".join(
f"{h.project_name}: {', '.join(h.attention_reasons[:2])}"
for h in needing[:5]
)
self.attention_list.setText(attention_html)
else:
self.attention_list.setText("All projects healthy!")
self.attention_list.setStyleSheet("color: #4caf50; line-height: 1.6;")
# Today's progress
progress_dir = self._docs_root / "progress"
if progress_dir.exists():
manager = ProgressLogManager(progress_dir)
today = manager.get_today()
if today:
items = []
for task in today.completed[:5]:
items.append(f"{task}")
for task in today.in_progress[:3]:
items.append(f"{task} (in progress)")
if items:
self.progress_list.setText("<br>".join(items))
else:
self.progress_list.setText("No progress logged yet today")
else:
self.progress_list.setText("No progress log for today")
else:
self.progress_list.setText("Progress directory not found")
def _on_project_clicked(self, project_key: str):
"""Handle project card click."""
self.project_selected.emit(project_key)
def refresh(self): def refresh(self):
"""Refresh the dashboard data.""" """Refresh the dashboard data."""
self._load_data() self._dashboard._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)

View File

@ -182,6 +182,7 @@ class TodoItemWidget(QWidget):
toggled = pyqtSignal(object, bool) # Emits (todo, completed) toggled = pyqtSignal(object, bool) # Emits (todo, completed)
deleted = pyqtSignal(object) # Emits todo deleted = pyqtSignal(object) # Emits todo
start_discussion = 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): def __init__(self, todo, parent: QWidget | None = None, show_priority: bool = False):
"""Initialize todo item widget. """Initialize todo item widget.
@ -194,6 +195,8 @@ class TodoItemWidget(QWidget):
super().__init__(parent) super().__init__(parent)
self.todo = todo self.todo = todo
self._show_priority = show_priority self._show_priority = show_priority
self._editing = False
self._edit_widget = None
self._setup_ui() self._setup_ui()
def _setup_ui(self): def _setup_ui(self):
@ -210,9 +213,11 @@ class TodoItemWidget(QWidget):
self.checkbox.setFixedWidth(20) self.checkbox.setFixedWidth(20)
layout.addWidget(self.checkbox) layout.addWidget(self.checkbox)
# Text # Text (double-click to edit)
self.text_label = QLabel(self.todo.text) self.text_label = QLabel(self.todo.text)
self.text_label.setWordWrap(True) self.text_label.setWordWrap(True)
self.text_label.setCursor(Qt.CursorShape.IBeamCursor)
self.text_label.mouseDoubleClickEvent = self._start_editing
self._update_text_style() self._update_text_style()
layout.addWidget(self.text_label, 1) layout.addWidget(self.text_label, 1)
@ -294,6 +299,91 @@ class TodoItemWidget(QWidget):
self.delete_btn.hide() self.delete_btn.hide()
super().leaveEvent(event) 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): def contextMenuEvent(self, event):
"""Show context menu on right-click.""" """Show context menu on right-click."""
menu = QMenu(self) menu = QMenu(self)
@ -326,18 +416,23 @@ class TodoItemWidget(QWidget):
class GoalItemWidget(QWidget): class GoalItemWidget(QWidget):
"""Widget for displaying a single goal item with checkbox.""" """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 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. """Initialize goal item widget.
Args: Args:
goal: Goal model instance goal: Goal model instance
parent: Parent widget parent: Parent widget
is_non_goal: If True, show green when checked instead of strikethrough
""" """
super().__init__(parent) super().__init__(parent)
self.goal = goal self.goal = goal
self._is_non_goal = is_non_goal
self._editing = False
self._edit_widget = None
self._setup_ui() self._setup_ui()
def _setup_ui(self): def _setup_ui(self):
@ -354,9 +449,11 @@ class GoalItemWidget(QWidget):
self.checkbox.setFixedWidth(20) self.checkbox.setFixedWidth(20)
layout.addWidget(self.checkbox) layout.addWidget(self.checkbox)
# Text # Text (double-click to edit)
self.text_label = QLabel(self.goal.text) self.text_label = QLabel(self.goal.text)
self.text_label.setWordWrap(True) self.text_label.setWordWrap(True)
self.text_label.setCursor(Qt.CursorShape.IBeamCursor)
self.text_label.mouseDoubleClickEvent = self._start_editing
self._update_text_style() self._update_text_style()
layout.addWidget(self.text_label, 1) layout.addWidget(self.text_label, 1)
@ -394,29 +491,56 @@ class GoalItemWidget(QWidget):
layout.addWidget(self.delete_btn) layout.addWidget(self.delete_btn)
def _update_checkbox(self): def _update_checkbox(self):
"""Update checkbox display.""" """Update checkbox display for three states."""
if self.goal.completed: if self.goal.completed:
self.checkbox.setText("") self.checkbox.setText("")
self.checkbox.setStyleSheet("color: #4caf50; font-size: 16px;") 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: else:
self.checkbox.setText("") self.checkbox.setText("")
self.checkbox.setStyleSheet("color: #888888; font-size: 16px;") self.checkbox.setStyleSheet("color: #888888; font-size: 16px;")
def _update_text_style(self): 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: if self.goal.completed:
self.text_label.setStyleSheet( # Achieved: green text, no strikethrough
"color: #666666; text-decoration: line-through; font-size: 13px;" 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: else:
# Not achieved: white text
self.text_label.setStyleSheet("color: #e0e0e0; font-size: 13px;") self.text_label.setStyleSheet("color: #e0e0e0; font-size: 13px;")
def _on_checkbox_clicked(self, event): def _on_checkbox_clicked(self, event):
"""Handle checkbox click.""" """Handle checkbox click - cycle through states.
self.goal.completed = not self.goal.completed
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_checkbox()
self._update_text_style() 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): def enterEvent(self, event):
"""Show delete button on hover.""" """Show delete button on hover."""
@ -428,6 +552,91 @@ class GoalItemWidget(QWidget):
self.delete_btn.hide() self.delete_btn.hide()
super().leaveEvent(event) 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): class DeliverableItemWidget(QWidget):
"""Widget for displaying a single deliverable item with checkbox.""" """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_deleted = pyqtSignal(object) # (todo) - for linked todos mode
todo_added = pyqtSignal(str, str, str) # (text, priority, milestone_id) - for adding new todos 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_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__( def __init__(
self, self,
@ -607,6 +817,10 @@ class MilestoneWidget(QFrame):
self.title_label.setStyleSheet("font-weight: bold; color: #e0e0e0; font-size: 13px;") self.title_label.setStyleSheet("font-weight: bold; color: #e0e0e0; font-size: 13px;")
header_layout.addWidget(self.title_label) 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 # Progress bar right after label
self.progress_bar = QProgressBar() self.progress_bar = QProgressBar()
self.progress_bar.setMinimum(0) self.progress_bar.setMinimum(0)
@ -734,12 +948,20 @@ class MilestoneWidget(QFrame):
self.arrow_label.setStyleSheet("color: #888888; font-size: 10px;") self.arrow_label.setStyleSheet("color: #888888; font-size: 10px;")
def _update_status_icon(self): 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 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.setText("")
self.status_icon.setStyleSheet("color: #4caf50; font-size: 14px;") 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.setText("")
self.status_icon.setStyleSheet("color: #4a9eff; font-size: 14px;") self.status_icon.setStyleSheet("color: #4a9eff; font-size: 14px;")
else: else:
@ -798,6 +1020,7 @@ class MilestoneWidget(QFrame):
widget.toggled.connect(self._on_todo_toggled_internal) widget.toggled.connect(self._on_todo_toggled_internal)
widget.deleted.connect(self._on_todo_deleted_internal) widget.deleted.connect(self._on_todo_deleted_internal)
widget.start_discussion.connect(self.todo_start_discussion.emit) widget.start_discussion.connect(self.todo_start_discussion.emit)
widget.edited.connect(self.todo_edited.emit)
self.deliverables_layout.addWidget(widget) self.deliverables_layout.addWidget(widget)
else: else:
# Legacy: Add deliverables # Legacy: Add deliverables

View File

@ -1,7 +1,7 @@
"""Project health card widget.""" """Project health card widget."""
from PyQt6.QtCore import Qt, pyqtSignal 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): class HealthCard(QFrame):
@ -158,7 +158,35 @@ class HealthCardCompact(QFrame):
# Name # Name
self.name_label = QLabel() self.name_label = QLabel()
self.name_label.setStyleSheet("color: #e0e0e0;") 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 # Time
self.time_label = QLabel() self.time_label = QLabel()
@ -177,6 +205,7 @@ class HealthCardCompact(QFrame):
status_icon: str, status_icon: str,
time_since_commit: str, time_since_commit: str,
active_todos: int, active_todos: int,
milestone_progress: int = 0,
): ):
"""Set project data.""" """Set project data."""
self._project_key = project_key self._project_key = project_key
@ -185,6 +214,31 @@ class HealthCardCompact(QFrame):
self.time_label.setText(time_since_commit) self.time_label.setText(time_since_commit)
self.todos_label.setText(f"📊 {active_todos}") 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): def mousePressEvent(self, event):
"""Handle mouse press.""" """Handle mouse press."""
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:

View File

@ -307,7 +307,17 @@ class PaneWidget(QFrame):
"title": title, "title": title,
"cwd": str(widget.cwd), "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 { return {
"tabs": tabs, "tabs": tabs,
@ -316,15 +326,34 @@ class PaneWidget(QFrame):
def restore_tabs(self, state: dict): def restore_tabs(self, state: dict):
"""Restore tabs from session state.""" """Restore tabs from session state."""
from development_hub.project_discovery import discover_projects
tabs = state.get("tabs", []) tabs = state.get("tabs", [])
current_tab = state.get("current_tab", 0) 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: 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()))) cwd = Path(tab_info.get("cwd", str(Path.home())))
title = tab_info.get("title", "Terminal") title = tab_info.get("title", "Terminal")
self.add_terminal(cwd, title) 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 # Restore current tab selection
if 0 <= current_tab < self.tab_widget.count(): if 0 <= current_tab < self.tab_widget.count():
self.tab_widget.setCurrentIndex(current_tab) self.tab_widget.setCurrentIndex(current_tab)

0
tests/__init__.py Normal file
View File

View File

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