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:
rob 2026-01-23 01:24:57 -04:00
parent a6937463d0
commit ce608957e5
5 changed files with 288 additions and 5 deletions

View File

@ -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 |

View File

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

View File

@ -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

View File

@ -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

View File

@ -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