fix: Dashboard bug fixes and improvements
Critical bug fixes: - Fix edit undo crash (5-tuple vs 4-tuple handling) - Fix milestone tag lost on undo (store todo.milestone in undo data) - Fix global mode null safety (guards for 8 functions) - Fix ideas priority not saved/loaded - Fix goal deletion for all sections (active/future/non_goals) - Fix goal partial-state undo/redo (widget now emits previous state) - Fix non-goals without checkboxes being dropped on save New features: - Add undo/redo support for goals and ideas (toggle, delete, edit) - Add atomic_write() for non-destructive file saves - Add round-trip tests for parsers Improvements: - Consolidate duplicated milestone todo handlers (delegate to main handlers) - Milestones now save with Active/Completed sections - Global dashboard shows milestone completion ratio 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5c45f7e3f0
commit
69ec7df308
|
|
@ -55,6 +55,7 @@ class Milestone:
|
||||||
progress: int = 0 # 0-100
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 "[ ]"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,0 +1,395 @@
|
||||||
|
"""Round-trip tests for parsers.
|
||||||
|
|
||||||
|
These tests verify that parse -> save -> parse produces identical data.
|
||||||
|
This ensures no data loss during save operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from development_hub.parsers.todos_parser import TodosParser
|
||||||
|
from development_hub.parsers.goals_parser import GoalsParser, MilestonesParser, GoalsSaver
|
||||||
|
from development_hub.models.todo import Todo, TodoList
|
||||||
|
from development_hub.models.goal import Goal, GoalList, Milestone, Deliverable, MilestoneStatus, DeliverableStatus
|
||||||
|
|
||||||
|
|
||||||
|
class TestTodosRoundtrip:
|
||||||
|
"""Test TodosParser round-trip preservation."""
|
||||||
|
|
||||||
|
def test_simple_todos_roundtrip(self, tmp_path):
|
||||||
|
"""Parse -> save -> parse should produce identical data."""
|
||||||
|
todos_file = tmp_path / "todos.md"
|
||||||
|
original_content = """---
|
||||||
|
type: todos
|
||||||
|
project: test-project
|
||||||
|
updated: 2026-01-08
|
||||||
|
---
|
||||||
|
|
||||||
|
# TODOs
|
||||||
|
|
||||||
|
## Active Tasks
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
|
||||||
|
- [ ] Important task #high
|
||||||
|
- [ ] Another important task #high @M1
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
|
||||||
|
- [ ] Medium task #medium
|
||||||
|
- [x] Completed medium task #medium (2026-01-07)
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
|
||||||
|
- [ ] Low priority task #low
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- [x] Done task #high (2026-01-06)
|
||||||
|
- [x] Another done #medium (2026-01-05)
|
||||||
|
"""
|
||||||
|
todos_file.write_text(original_content)
|
||||||
|
|
||||||
|
# First parse
|
||||||
|
parser1 = TodosParser(todos_file)
|
||||||
|
todo_list1 = parser1.parse()
|
||||||
|
|
||||||
|
# Save
|
||||||
|
parser1.save(todo_list1)
|
||||||
|
|
||||||
|
# Second parse
|
||||||
|
parser2 = TodosParser(todos_file)
|
||||||
|
todo_list2 = parser2.parse()
|
||||||
|
|
||||||
|
# Compare
|
||||||
|
assert len(todo_list1.all_todos) == len(todo_list2.all_todos)
|
||||||
|
assert len(todo_list1.completed) == len(todo_list2.completed)
|
||||||
|
|
||||||
|
# Compare individual todos
|
||||||
|
for t1, t2 in zip(todo_list1.all_todos, todo_list2.all_todos):
|
||||||
|
assert t1.text == t2.text
|
||||||
|
assert t1.priority == t2.priority
|
||||||
|
assert t1.completed == t2.completed
|
||||||
|
assert t1.milestone == t2.milestone
|
||||||
|
|
||||||
|
def test_todos_with_milestones_roundtrip(self, tmp_path):
|
||||||
|
"""Milestone tags should be preserved."""
|
||||||
|
todos_file = tmp_path / "todos.md"
|
||||||
|
original_content = """---
|
||||||
|
type: todos
|
||||||
|
project: test
|
||||||
|
---
|
||||||
|
|
||||||
|
# TODOs
|
||||||
|
|
||||||
|
## Active Tasks
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
|
||||||
|
- [ ] Task with milestone @M1 #high
|
||||||
|
- [ ] Task with M2 @M2 #high
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
"""
|
||||||
|
todos_file.write_text(original_content)
|
||||||
|
|
||||||
|
parser1 = TodosParser(todos_file)
|
||||||
|
todo_list1 = parser1.parse()
|
||||||
|
|
||||||
|
# Verify milestones parsed
|
||||||
|
high_todos = [t for t in todo_list1.all_todos if t.priority == "high"]
|
||||||
|
assert any(t.milestone == "M1" for t in high_todos)
|
||||||
|
assert any(t.milestone == "M2" for t in high_todos)
|
||||||
|
|
||||||
|
# Save and reparse
|
||||||
|
parser1.save(todo_list1)
|
||||||
|
parser2 = TodosParser(todos_file)
|
||||||
|
todo_list2 = parser2.parse()
|
||||||
|
|
||||||
|
# Verify milestones preserved
|
||||||
|
high_todos2 = [t for t in todo_list2.all_todos if t.priority == "high"]
|
||||||
|
assert any(t.milestone == "M1" for t in high_todos2)
|
||||||
|
assert any(t.milestone == "M2" for t in high_todos2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGoalsRoundtrip:
|
||||||
|
"""Test GoalsParser round-trip preservation."""
|
||||||
|
|
||||||
|
def test_goals_roundtrip(self, tmp_path):
|
||||||
|
"""Goals should be preserved across parse -> save -> parse."""
|
||||||
|
goals_file = tmp_path / "goals.md"
|
||||||
|
original_content = """---
|
||||||
|
type: goals
|
||||||
|
project: test-project
|
||||||
|
updated: 2026-01-08
|
||||||
|
---
|
||||||
|
|
||||||
|
# Goals
|
||||||
|
|
||||||
|
## Active
|
||||||
|
|
||||||
|
- [ ] Incomplete goal #high
|
||||||
|
- [~] Partial goal #medium
|
||||||
|
- [x] Completed goal #low
|
||||||
|
|
||||||
|
## Future
|
||||||
|
|
||||||
|
- [ ] Future goal 1 #medium
|
||||||
|
- [ ] Future goal 2 #low
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- [ ] Not doing this #medium
|
||||||
|
- [x] Confirmed non-goal #high
|
||||||
|
"""
|
||||||
|
goals_file.write_text(original_content)
|
||||||
|
|
||||||
|
# First parse
|
||||||
|
parser1 = GoalsParser(goals_file)
|
||||||
|
goal_list1 = parser1.parse()
|
||||||
|
|
||||||
|
# Save using GoalsSaver
|
||||||
|
saver = GoalsSaver(goals_file, parser1.frontmatter)
|
||||||
|
saver.save(goal_list1)
|
||||||
|
|
||||||
|
# Second parse
|
||||||
|
parser2 = GoalsParser(goals_file)
|
||||||
|
goal_list2 = parser2.parse()
|
||||||
|
|
||||||
|
# Compare counts
|
||||||
|
assert len(goal_list1.active) == len(goal_list2.active)
|
||||||
|
assert len(goal_list1.future) == len(goal_list2.future)
|
||||||
|
assert len(goal_list1.non_goals) == len(goal_list2.non_goals)
|
||||||
|
|
||||||
|
# Compare active goals
|
||||||
|
for g1, g2 in zip(goal_list1.active, goal_list2.active):
|
||||||
|
assert g1.text == g2.text
|
||||||
|
assert g1.completed == g2.completed
|
||||||
|
assert g1.partial == g2.partial
|
||||||
|
assert g1.priority == g2.priority
|
||||||
|
|
||||||
|
def test_three_state_goals_roundtrip(self, tmp_path):
|
||||||
|
"""Three-state goals ([ ], [~], [x]) should be preserved."""
|
||||||
|
goals_file = tmp_path / "goals.md"
|
||||||
|
original_content = """---
|
||||||
|
type: goals
|
||||||
|
project: test
|
||||||
|
---
|
||||||
|
|
||||||
|
# Goals
|
||||||
|
|
||||||
|
## Active
|
||||||
|
|
||||||
|
- [ ] Not started #medium
|
||||||
|
- [~] In progress #high
|
||||||
|
- [x] Done #low
|
||||||
|
"""
|
||||||
|
goals_file.write_text(original_content)
|
||||||
|
|
||||||
|
parser1 = GoalsParser(goals_file)
|
||||||
|
goal_list1 = parser1.parse()
|
||||||
|
|
||||||
|
# Verify states parsed correctly
|
||||||
|
assert goal_list1.active[0].completed == False
|
||||||
|
assert goal_list1.active[0].partial == False
|
||||||
|
assert goal_list1.active[1].completed == False
|
||||||
|
assert goal_list1.active[1].partial == True
|
||||||
|
assert goal_list1.active[2].completed == True
|
||||||
|
assert goal_list1.active[2].partial == False
|
||||||
|
|
||||||
|
# Save and reparse
|
||||||
|
saver = GoalsSaver(goals_file, parser1.frontmatter)
|
||||||
|
saver.save(goal_list1)
|
||||||
|
|
||||||
|
parser2 = GoalsParser(goals_file)
|
||||||
|
goal_list2 = parser2.parse()
|
||||||
|
|
||||||
|
# Verify states preserved
|
||||||
|
assert goal_list2.active[0].completed == False
|
||||||
|
assert goal_list2.active[0].partial == False
|
||||||
|
assert goal_list2.active[1].completed == False
|
||||||
|
assert goal_list2.active[1].partial == True
|
||||||
|
assert goal_list2.active[2].completed == True
|
||||||
|
assert goal_list2.active[2].partial == False
|
||||||
|
|
||||||
|
|
||||||
|
class TestMilestonesRoundtrip:
|
||||||
|
"""Test MilestonesParser round-trip preservation."""
|
||||||
|
|
||||||
|
def test_milestones_roundtrip(self, tmp_path):
|
||||||
|
"""Milestones should be preserved across parse -> save -> parse."""
|
||||||
|
milestones_file = tmp_path / "milestones.md"
|
||||||
|
original_content = """---
|
||||||
|
type: milestones
|
||||||
|
project: test-project
|
||||||
|
updated: 2026-01-08
|
||||||
|
---
|
||||||
|
|
||||||
|
# Milestones
|
||||||
|
|
||||||
|
#### M1: First Milestone
|
||||||
|
**Target**: January 2026
|
||||||
|
**Status**: In Progress (50%)
|
||||||
|
|
||||||
|
First milestone description.
|
||||||
|
|
||||||
|
| Deliverable | Status |
|
||||||
|
|-------------|--------|
|
||||||
|
| Task A | Done |
|
||||||
|
| Task B | In Progress |
|
||||||
|
| Task C | Not Started |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### M2: Second Milestone
|
||||||
|
**Target**: February 2026
|
||||||
|
**Status**: Not Started
|
||||||
|
|
||||||
|
Second milestone description.
|
||||||
|
|
||||||
|
| Deliverable | Status |
|
||||||
|
|-------------|--------|
|
||||||
|
| Feature X | Not Started |
|
||||||
|
| Feature Y | Not Started |
|
||||||
|
|
||||||
|
---
|
||||||
|
"""
|
||||||
|
milestones_file.write_text(original_content)
|
||||||
|
|
||||||
|
# First parse
|
||||||
|
parser1 = MilestonesParser(milestones_file)
|
||||||
|
milestones1 = parser1.parse()
|
||||||
|
|
||||||
|
# Save
|
||||||
|
parser1.save(milestones1)
|
||||||
|
|
||||||
|
# Second parse
|
||||||
|
parser2 = MilestonesParser(milestones_file)
|
||||||
|
milestones2 = parser2.parse()
|
||||||
|
|
||||||
|
# Compare counts
|
||||||
|
assert len(milestones1) == len(milestones2)
|
||||||
|
|
||||||
|
# Compare individual milestones
|
||||||
|
for m1, m2 in zip(milestones1, milestones2):
|
||||||
|
assert m1.id == m2.id
|
||||||
|
assert m1.name == m2.name
|
||||||
|
assert m1.target == m2.target
|
||||||
|
assert m1.status == m2.status
|
||||||
|
assert len(m1.deliverables) == len(m2.deliverables)
|
||||||
|
|
||||||
|
# Compare deliverables
|
||||||
|
for d1, d2 in zip(m1.deliverables, m2.deliverables):
|
||||||
|
assert d1.name == d2.name
|
||||||
|
assert d1.status == d2.status
|
||||||
|
|
||||||
|
def test_milestone_status_roundtrip(self, tmp_path):
|
||||||
|
"""Milestone statuses should be preserved."""
|
||||||
|
milestones_file = tmp_path / "milestones.md"
|
||||||
|
original_content = """---
|
||||||
|
type: milestones
|
||||||
|
project: test
|
||||||
|
---
|
||||||
|
|
||||||
|
# Milestones
|
||||||
|
|
||||||
|
#### M1: Complete
|
||||||
|
**Target**: Dec 2025
|
||||||
|
**Status**: Completed (100%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### M2: In Progress
|
||||||
|
**Target**: Jan 2026
|
||||||
|
**Status**: In Progress (75%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### M3: Planning
|
||||||
|
**Target**: Feb 2026
|
||||||
|
**Status**: Planning (10%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### M4: Not Started
|
||||||
|
**Target**: Q2 2026
|
||||||
|
**Status**: Not Started
|
||||||
|
|
||||||
|
---
|
||||||
|
"""
|
||||||
|
milestones_file.write_text(original_content)
|
||||||
|
|
||||||
|
parser1 = MilestonesParser(milestones_file)
|
||||||
|
milestones1 = parser1.parse()
|
||||||
|
|
||||||
|
# Create lookup by ID
|
||||||
|
by_id1 = {m.id: m for m in milestones1}
|
||||||
|
|
||||||
|
# Verify statuses parsed
|
||||||
|
assert by_id1["M1"].status == MilestoneStatus.COMPLETE
|
||||||
|
assert by_id1["M2"].status == MilestoneStatus.IN_PROGRESS
|
||||||
|
assert by_id1["M3"].status == MilestoneStatus.PLANNING
|
||||||
|
assert by_id1["M4"].status == MilestoneStatus.NOT_STARTED
|
||||||
|
|
||||||
|
# Save and reparse
|
||||||
|
parser1.save(milestones1)
|
||||||
|
parser2 = MilestonesParser(milestones_file)
|
||||||
|
milestones2 = parser2.parse()
|
||||||
|
|
||||||
|
# Create lookup by ID
|
||||||
|
by_id2 = {m.id: m for m in milestones2}
|
||||||
|
|
||||||
|
# Verify statuses preserved (order may change due to Active/Completed sections)
|
||||||
|
assert by_id2["M1"].status == MilestoneStatus.COMPLETE
|
||||||
|
assert by_id2["M2"].status == MilestoneStatus.IN_PROGRESS
|
||||||
|
assert by_id2["M3"].status == MilestoneStatus.PLANNING
|
||||||
|
assert by_id2["M4"].status == MilestoneStatus.NOT_STARTED
|
||||||
|
|
||||||
|
|
||||||
|
class TestAtomicWrite:
|
||||||
|
"""Test atomic write functionality."""
|
||||||
|
|
||||||
|
def test_atomic_write_creates_file(self, tmp_path):
|
||||||
|
"""atomic_write should create file if it doesn't exist."""
|
||||||
|
from development_hub.parsers.base import atomic_write
|
||||||
|
|
||||||
|
test_file = tmp_path / "new_file.md"
|
||||||
|
content = "# Test Content\n\nSome text here."
|
||||||
|
|
||||||
|
atomic_write(test_file, content)
|
||||||
|
|
||||||
|
assert test_file.exists()
|
||||||
|
assert test_file.read_text() == content
|
||||||
|
|
||||||
|
def test_atomic_write_overwrites_file(self, tmp_path):
|
||||||
|
"""atomic_write should safely overwrite existing file."""
|
||||||
|
from development_hub.parsers.base import atomic_write
|
||||||
|
|
||||||
|
test_file = tmp_path / "existing.md"
|
||||||
|
test_file.write_text("Original content")
|
||||||
|
|
||||||
|
new_content = "New content here"
|
||||||
|
atomic_write(test_file, new_content)
|
||||||
|
|
||||||
|
assert test_file.read_text() == new_content
|
||||||
|
|
||||||
|
def test_atomic_write_no_temp_files_left(self, tmp_path):
|
||||||
|
"""atomic_write should not leave temp files after success."""
|
||||||
|
from development_hub.parsers.base import atomic_write
|
||||||
|
|
||||||
|
test_file = tmp_path / "test.md"
|
||||||
|
atomic_write(test_file, "content")
|
||||||
|
|
||||||
|
# Check no temp files remain
|
||||||
|
temp_files = list(tmp_path.glob(".test.md.*"))
|
||||||
|
assert len(temp_files) == 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
Loading…
Reference in New Issue