Add weekly progress report and fix terminal Tab key
- Add Reports menu with Weekly Progress Report dialog (Ctrl+R) - Report aggregates daily standups into summary with completed items, in-progress work, blockers, and focus areas - Support 7/14/30 day ranges with copy-to-clipboard - Fix Tab key in terminal widget by intercepting in event() before Qt's focus navigation consumes it - Add Shift+Tab (backtab) support for reverse completion - Improve AI output parsing in dashboard to handle extended thinking Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a6937463d0
commit
ce608957e5
|
|
@ -55,6 +55,7 @@ src/development_hub/
|
||||||
- **Drag & Drop**: Drop files/folders into terminal to inject paths
|
- **Drag & Drop**: Drop files/folders into terminal to inject paths
|
||||||
- **Session Persistence**: Remembers pane layout and open terminals
|
- **Session Persistence**: Remembers pane layout and open terminals
|
||||||
- **New Project Dialog**: Integrates with Ramble for voice input
|
- **New Project Dialog**: Integrates with Ramble for voice input
|
||||||
|
- **Progress Reports**: Export weekly progress summaries from daily standups
|
||||||
|
|
||||||
### Keyboard Shortcuts
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
|
@ -71,6 +72,7 @@ src/development_hub/
|
||||||
| `Ctrl+B` | Toggle project panel |
|
| `Ctrl+B` | Toggle project panel |
|
||||||
| `Ctrl+N` | New project dialog |
|
| `Ctrl+N` | New project dialog |
|
||||||
| `Ctrl+D` | New discussion |
|
| `Ctrl+D` | New discussion |
|
||||||
|
| `Ctrl+R` | Weekly progress report |
|
||||||
| `Ctrl+Z` | Undo (in dashboard) |
|
| `Ctrl+Z` | Undo (in dashboard) |
|
||||||
| `Ctrl+Shift+Z` | Redo (in dashboard) |
|
| `Ctrl+Shift+Z` | Redo (in dashboard) |
|
||||||
| `F5` | Refresh project list |
|
| `F5` | Refresh project list |
|
||||||
|
|
|
||||||
|
|
@ -1611,3 +1611,241 @@ class ReAlignGoalsDialog(QDialog):
|
||||||
"""Handle generation error."""
|
"""Handle generation error."""
|
||||||
QMessageBox.critical(self, "Generation Error", error)
|
QMessageBox.critical(self, "Generation Error", error)
|
||||||
self.reject()
|
self.reject()
|
||||||
|
|
||||||
|
|
||||||
|
class WeeklyReportDialog(QDialog):
|
||||||
|
"""Dialog for generating and exporting weekly progress reports."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Weekly Progress Report")
|
||||||
|
self.setMinimumWidth(700)
|
||||||
|
self.setMinimumHeight(500)
|
||||||
|
self._setup_ui()
|
||||||
|
self._generate_report()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
"""Set up the dialog UI."""
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Header with date range
|
||||||
|
from datetime import date, timedelta
|
||||||
|
today = date.today()
|
||||||
|
week_ago = today - timedelta(days=6)
|
||||||
|
header = QLabel(
|
||||||
|
f"<h2>Progress Report</h2>"
|
||||||
|
f"<p style='color: #888;'>{week_ago.strftime('%B %d')} - {today.strftime('%B %d, %Y')}</p>"
|
||||||
|
)
|
||||||
|
layout.addWidget(header)
|
||||||
|
|
||||||
|
# Days selector
|
||||||
|
days_layout = QHBoxLayout()
|
||||||
|
days_layout.addWidget(QLabel("Include last:"))
|
||||||
|
self.days_combo = QComboBox()
|
||||||
|
self.days_combo.addItems(["7 days", "14 days", "30 days"])
|
||||||
|
self.days_combo.currentIndexChanged.connect(self._generate_report)
|
||||||
|
days_layout.addWidget(self.days_combo)
|
||||||
|
days_layout.addStretch()
|
||||||
|
layout.addLayout(days_layout)
|
||||||
|
|
||||||
|
# Report content
|
||||||
|
self.report_text = QPlainTextEdit()
|
||||||
|
self.report_text.setReadOnly(True)
|
||||||
|
self.report_text.setStyleSheet("""
|
||||||
|
QPlainTextEdit {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid #3d3d3d;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
layout.addWidget(self.report_text)
|
||||||
|
|
||||||
|
# Button row
|
||||||
|
btn_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
copy_btn = QPushButton("Copy to Clipboard")
|
||||||
|
copy_btn.clicked.connect(self._copy_to_clipboard)
|
||||||
|
btn_layout.addWidget(copy_btn)
|
||||||
|
|
||||||
|
btn_layout.addStretch()
|
||||||
|
|
||||||
|
close_btn = QPushButton("Close")
|
||||||
|
close_btn.clicked.connect(self.accept)
|
||||||
|
btn_layout.addWidget(close_btn)
|
||||||
|
|
||||||
|
layout.addLayout(btn_layout)
|
||||||
|
|
||||||
|
def _get_days(self) -> int:
|
||||||
|
"""Get selected number of days."""
|
||||||
|
text = self.days_combo.currentText()
|
||||||
|
if "14" in text:
|
||||||
|
return 14
|
||||||
|
elif "30" in text:
|
||||||
|
return 30
|
||||||
|
return 7
|
||||||
|
|
||||||
|
def _generate_report(self):
|
||||||
|
"""Generate the weekly report from progress logs."""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from development_hub.parsers.progress_parser import ProgressLogManager
|
||||||
|
|
||||||
|
days = self._get_days()
|
||||||
|
progress_dir = Path.home() / "PycharmProjects" / "project-docs" / "docs" / "progress"
|
||||||
|
|
||||||
|
if not progress_dir.exists():
|
||||||
|
self.report_text.setPlainText(
|
||||||
|
"No progress directory found.\n\n"
|
||||||
|
f"Expected: {progress_dir}\n\n"
|
||||||
|
"Use the Daily Standup feature to create progress entries."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
manager = ProgressLogManager(progress_dir)
|
||||||
|
entries = manager.get_recent(days)
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
self.report_text.setPlainText(
|
||||||
|
f"No progress entries found for the last {days} days.\n\n"
|
||||||
|
"Use the Daily Standup feature (Terminal menu > Standup) to log daily progress."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build report
|
||||||
|
report = self._build_report(entries, days)
|
||||||
|
self.report_text.setPlainText(report)
|
||||||
|
|
||||||
|
def _build_report(self, entries: list, days: int) -> str:
|
||||||
|
"""Build markdown report from progress entries."""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
start_date = today - timedelta(days=days - 1)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# Progress Report",
|
||||||
|
f"**{start_date.strftime('%B %d')} - {today.strftime('%B %d, %Y')}**",
|
||||||
|
f"",
|
||||||
|
f"---",
|
||||||
|
f"",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Aggregate data
|
||||||
|
all_projects = set()
|
||||||
|
all_completed = []
|
||||||
|
all_in_progress = []
|
||||||
|
all_blocked = []
|
||||||
|
focus_areas = []
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
all_projects.update(entry.projects)
|
||||||
|
|
||||||
|
for item in entry.completed:
|
||||||
|
all_completed.append((entry.date, item))
|
||||||
|
|
||||||
|
for item in entry.in_progress:
|
||||||
|
if item not in [i for _, i in all_completed]: # Skip if already completed
|
||||||
|
all_in_progress.append((entry.date, item))
|
||||||
|
|
||||||
|
for blocked in entry.blocked:
|
||||||
|
all_blocked.append((entry.date, blocked))
|
||||||
|
|
||||||
|
if entry.focus_primary:
|
||||||
|
focus_areas.append((entry.date, entry.focus_primary))
|
||||||
|
|
||||||
|
# Summary stats
|
||||||
|
lines.append("## Summary")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"- **Days logged:** {len(entries)}")
|
||||||
|
lines.append(f"- **Projects touched:** {', '.join(sorted(all_projects)) if all_projects else 'None'}")
|
||||||
|
lines.append(f"- **Tasks completed:** {len(all_completed)}")
|
||||||
|
lines.append(f"- **Currently in progress:** {len(set(i for _, i in all_in_progress))}")
|
||||||
|
if all_blocked:
|
||||||
|
lines.append(f"- **Blockers:** {len(all_blocked)}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Focus areas
|
||||||
|
if focus_areas:
|
||||||
|
lines.append("## Focus Areas")
|
||||||
|
lines.append("")
|
||||||
|
seen = set()
|
||||||
|
for date_str, focus in focus_areas:
|
||||||
|
if focus not in seen:
|
||||||
|
lines.append(f"- {focus}")
|
||||||
|
seen.add(focus)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Completed items
|
||||||
|
if all_completed:
|
||||||
|
lines.append("## Completed")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Group by date
|
||||||
|
by_date = defaultdict(list)
|
||||||
|
for date_str, item in all_completed:
|
||||||
|
by_date[date_str].append(item)
|
||||||
|
|
||||||
|
for date_str in sorted(by_date.keys(), reverse=True):
|
||||||
|
lines.append(f"### {date_str}")
|
||||||
|
for item in by_date[date_str]:
|
||||||
|
lines.append(f"- [x] {item}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# In Progress
|
||||||
|
if all_in_progress:
|
||||||
|
lines.append("## In Progress")
|
||||||
|
lines.append("")
|
||||||
|
seen = set()
|
||||||
|
for _, item in all_in_progress:
|
||||||
|
if item not in seen:
|
||||||
|
lines.append(f"- [ ] {item}")
|
||||||
|
seen.add(item)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Blockers
|
||||||
|
if all_blocked:
|
||||||
|
lines.append("## Blockers")
|
||||||
|
lines.append("")
|
||||||
|
for date_str, (issue, project, waiting) in all_blocked:
|
||||||
|
line = f"- **{issue}**"
|
||||||
|
if project:
|
||||||
|
line += f" ({project})"
|
||||||
|
if waiting:
|
||||||
|
line += f" - waiting on: {waiting}"
|
||||||
|
lines.append(line)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Daily breakdown
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Daily Log")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for entry in sorted(entries, key=lambda e: e.date, reverse=True):
|
||||||
|
lines.append(f"### {entry.date}")
|
||||||
|
if entry.focus_primary:
|
||||||
|
lines.append(f"**Focus:** {entry.focus_primary}")
|
||||||
|
if entry.completed:
|
||||||
|
lines.append(f"**Completed:** {len(entry.completed)} items")
|
||||||
|
if entry.notes:
|
||||||
|
lines.append(f"**Notes:** {entry.notes[:100]}{'...' if len(entry.notes) > 100 else ''}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _copy_to_clipboard(self):
|
||||||
|
"""Copy report to clipboard."""
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
QApplication.clipboard().setText(self.report_text.toPlainText())
|
||||||
|
|
||||||
|
# Show brief confirmation
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Copied",
|
||||||
|
"Report copied to clipboard!"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from PySide6.QtWidgets import (
|
||||||
from development_hub.project_discovery import Project
|
from development_hub.project_discovery import Project
|
||||||
from development_hub.project_list import ProjectListWidget
|
from development_hub.project_list import ProjectListWidget
|
||||||
from development_hub.workspace import WorkspaceManager
|
from development_hub.workspace import WorkspaceManager
|
||||||
from development_hub.dialogs import NewProjectDialog, SettingsDialog, StandupDialog
|
from development_hub.dialogs import NewProjectDialog, SettingsDialog, StandupDialog, WeeklyReportDialog
|
||||||
from development_hub.settings import Settings
|
from development_hub.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -166,6 +166,14 @@ class MainWindow(QMainWindow):
|
||||||
prev_pane.triggered.connect(self.workspace.focus_previous_pane)
|
prev_pane.triggered.connect(self.workspace.focus_previous_pane)
|
||||||
view_menu.addAction(prev_pane)
|
view_menu.addAction(prev_pane)
|
||||||
|
|
||||||
|
# Reports menu
|
||||||
|
reports_menu = menubar.addMenu("&Reports")
|
||||||
|
|
||||||
|
weekly_report = QAction("&Weekly Progress Report...", self)
|
||||||
|
weekly_report.setShortcut(QKeySequence("Ctrl+R"))
|
||||||
|
weekly_report.triggered.connect(self._show_weekly_report)
|
||||||
|
reports_menu.addAction(weekly_report)
|
||||||
|
|
||||||
# Terminal menu
|
# Terminal menu
|
||||||
terminal_menu = menubar.addMenu("&Terminal")
|
terminal_menu = menubar.addMenu("&Terminal")
|
||||||
|
|
||||||
|
|
@ -225,6 +233,11 @@ class MainWindow(QMainWindow):
|
||||||
dialog = StandupDialog(self)
|
dialog = StandupDialog(self)
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
|
|
||||||
|
def _show_weekly_report(self):
|
||||||
|
"""Show weekly progress report dialog."""
|
||||||
|
dialog = WeeklyReportDialog(self)
|
||||||
|
dialog.exec()
|
||||||
|
|
||||||
def _launch_discussion(self):
|
def _launch_discussion(self):
|
||||||
"""Launch orchestrated-discussions UI in the current project directory."""
|
"""Launch orchestrated-discussions UI in the current project directory."""
|
||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
|
||||||
|
|
@ -329,6 +329,26 @@ class TerminalDisplay(QWidget):
|
||||||
|
|
||||||
return self._color_to_qcolor(char.bg, default_fg=False)
|
return self._color_to_qcolor(char.bg, default_fg=False)
|
||||||
|
|
||||||
|
def event(self, event):
|
||||||
|
"""Override event to intercept Tab before Qt's focus handling."""
|
||||||
|
if event.type() == event.Type.KeyPress:
|
||||||
|
key = event.key()
|
||||||
|
modifiers = event.modifiers()
|
||||||
|
# Intercept Tab and Shift+Tab before Qt uses them for focus navigation
|
||||||
|
if key == Qt.Key.Key_Tab:
|
||||||
|
if modifiers == Qt.KeyboardModifier.ShiftModifier:
|
||||||
|
# Shift+Tab (backtab)
|
||||||
|
self.key_pressed.emit(b'\x1b[Z')
|
||||||
|
else:
|
||||||
|
# Regular Tab
|
||||||
|
self.key_pressed.emit(b'\t')
|
||||||
|
return True
|
||||||
|
elif key == Qt.Key.Key_Backtab:
|
||||||
|
# Backtab (some systems report this instead of Shift+Tab)
|
||||||
|
self.key_pressed.emit(b'\x1b[Z')
|
||||||
|
return True
|
||||||
|
return super().event(event)
|
||||||
|
|
||||||
def keyPressEvent(self, event: QKeyEvent):
|
def keyPressEvent(self, event: QKeyEvent):
|
||||||
"""Handle keyboard input and send to PTY."""
|
"""Handle keyboard input and send to PTY."""
|
||||||
key = event.key()
|
key = event.key()
|
||||||
|
|
@ -390,9 +410,7 @@ class TerminalDisplay(QWidget):
|
||||||
elif key == Qt.Key.Key_Backspace:
|
elif key == Qt.Key.Key_Backspace:
|
||||||
self.key_pressed.emit(b'\x7f')
|
self.key_pressed.emit(b'\x7f')
|
||||||
return
|
return
|
||||||
elif key == Qt.Key.Key_Tab:
|
# Tab is handled in event() to intercept before Qt's focus navigation
|
||||||
self.key_pressed.emit(b'\t')
|
|
||||||
return
|
|
||||||
elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter:
|
elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter:
|
||||||
self.key_pressed.emit(b'\r')
|
self.key_pressed.emit(b'\r')
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -2445,14 +2445,26 @@ class ProjectDashboard(QWidget):
|
||||||
|
|
||||||
# Try to parse as JSON
|
# Try to parse as JSON
|
||||||
try:
|
try:
|
||||||
# Clean up output - remove any markdown code blocks
|
# Clean up output - remove thinking tags and markdown code blocks
|
||||||
json_str = output.strip()
|
json_str = output.strip()
|
||||||
|
|
||||||
|
# Remove thinking output (e.g., from extended thinking models)
|
||||||
|
if "</think>" in json_str:
|
||||||
|
json_str = json_str.split("</think>")[-1].strip()
|
||||||
|
|
||||||
|
# Remove markdown code blocks
|
||||||
if json_str.startswith("```"):
|
if json_str.startswith("```"):
|
||||||
json_str = json_str.split("```")[1]
|
json_str = json_str.split("```")[1]
|
||||||
if json_str.startswith("json"):
|
if json_str.startswith("json"):
|
||||||
json_str = json_str[4:]
|
json_str = json_str[4:]
|
||||||
json_str = json_str.strip()
|
json_str = json_str.strip()
|
||||||
|
|
||||||
|
# Find the JSON object by locating first { and last }
|
||||||
|
first_brace = json_str.find("{")
|
||||||
|
last_brace = json_str.rfind("}")
|
||||||
|
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
|
||||||
|
json_str = json_str[first_brace:last_brace + 1]
|
||||||
|
|
||||||
audit_data = json.loads(json_str)
|
audit_data = json.loads(json_str)
|
||||||
|
|
||||||
# Update the goals.md file with new checkbox states
|
# Update the goals.md file with new checkbox states
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue