"""Kanban board window for managing memory entry lifecycle.""" from __future__ import annotations from datetime import datetime, timedelta from pathlib import Path from PySide6.QtCore import QTimer, Qt from PySide6.QtWidgets import ( QApplication, QFrame, QHBoxLayout, QLabel, QMenu, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, ) from my_memory.config import Config from my_memory.models import Entry, EntrySource, EntryStatus from my_memory.storage import load_all_entries, update_entry_status # Column definitions: (display name, status, accent color) _COLUMNS: list[tuple[str, EntryStatus, str]] = [ ("On Docket", EntryStatus.DOCKET, "#f9e2af"), ("In Progress", EntryStatus.IN_PROGRESS, "#cba6f7"), ("Complete", EntryStatus.COMPLETE, "#a6e3a1"), ] # Adjacent moves for inline buttons _STATUS_ORDER = [EntryStatus.DOCKET, EntryStatus.IN_PROGRESS, EntryStatus.COMPLETE] def _relative_time(dt: datetime) -> str: """Return a human-readable relative timestamp.""" delta = datetime.now() - dt seconds = int(delta.total_seconds()) if seconds < 60: return "just now" minutes = seconds // 60 if minutes < 60: return f"{minutes}m ago" hours = minutes // 60 if hours < 24: return f"{hours}h ago" days = hours // 24 if days < 30: return f"{days}d ago" return dt.strftime("%b %d") class EntryCard(QFrame): """A single entry card in a kanban column.""" def __init__( self, filepath: Path, entry: Entry, on_status_change: callable, parent: QWidget | None = None, ): super().__init__(parent) self._filepath = filepath self._entry = entry self._on_status_change = on_status_change self.setObjectName("entryCard") self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._show_context_menu) self._setup_ui() def _setup_ui(self) -> None: layout = QVBoxLayout(self) layout.setContentsMargins(10, 8, 10, 8) layout.setSpacing(6) # Content preview (first ~120 chars, max 3 lines) preview_text = self._entry.content[:120] if len(self._entry.content) > 120: preview_text += "..." content_label = QLabel(preview_text) content_label.setObjectName("cardContent") content_label.setWordWrap(True) content_label.setMaximumHeight(54) layout.addWidget(content_label) # Meta row: source + timestamp + tags meta_parts: list[str] = [] source_icon = "\U0001f3a4" if self._entry.source == EntrySource.VOICE else "\u270d" meta_parts.append(source_icon) meta_parts.append(_relative_time(self._entry.timestamp)) if self._entry.tags: meta_parts.append(" ".join(f"#{t}" for t in self._entry.tags[:3])) meta_label = QLabel(" ".join(meta_parts)) meta_label.setObjectName("cardMeta") layout.addWidget(meta_label) # Action buttons row btn_layout = QHBoxLayout() btn_layout.setSpacing(4) btn_layout.addStretch() idx = _STATUS_ORDER.index(self._entry.status) if idx > 0: prev_status = _STATUS_ORDER[idx - 1] left_btn = QPushButton(f"\u2190 {_label_for(prev_status)}") left_btn.setObjectName("moveBtn") left_btn.setCursor(Qt.CursorShape.PointingHandCursor) left_btn.clicked.connect(lambda: self._move_to(prev_status)) btn_layout.addWidget(left_btn) if idx < len(_STATUS_ORDER) - 1: next_status = _STATUS_ORDER[idx + 1] right_btn = QPushButton(f"{_label_for(next_status)} \u2192") right_btn.setObjectName("moveBtn") right_btn.setCursor(Qt.CursorShape.PointingHandCursor) right_btn.clicked.connect(lambda: self._move_to(next_status)) btn_layout.addWidget(right_btn) layout.addLayout(btn_layout) def _move_to(self, new_status: EntryStatus) -> None: update_entry_status(self._filepath, new_status) self._on_status_change() def _show_context_menu(self, pos) -> None: menu = QMenu(self) menu.setStyleSheet(""" QMenu { background: #313244; color: #cdd6f4; border: 1px solid #45475a; border-radius: 4px; padding: 4px; } QMenu::item:selected { background: #45475a; } """) for status in EntryStatus: if status == self._entry.status: continue action = menu.addAction(f"Move to {_label_for(status)}") action.triggered.connect(lambda _checked, s=status: self._move_to(s)) menu.exec(self.mapToGlobal(pos)) def _label_for(status: EntryStatus) -> str: """Short display label for a status.""" return { EntryStatus.DOCKET: "On Docket", EntryStatus.IN_PROGRESS: "In Progress", EntryStatus.COMPLETE: "Complete", }[status] class KanbanColumn(QWidget): """A single column in the kanban board.""" def __init__( self, name: str, status: EntryStatus, accent: str, collapsible: bool = False, default_filter_days: int = 7, parent: QWidget | None = None, ): super().__init__(parent) self._name = name self._status = status self._accent = accent self._collapsible = collapsible self._collapsed = collapsible # start collapsed if collapsible self._filter_days: int | None = default_filter_days if collapsible else None self._all_entries: list[tuple[Path, Entry]] = [] self._on_status_change: callable | None = None self._setup_ui() def _setup_ui(self) -> None: layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # Column header with count badge self._header = QLabel(self._name) self._header.setObjectName("columnHeader") self._header.setStyleSheet(f""" #columnHeader {{ color: #cdd6f4; font-size: 14px; font-weight: bold; padding: 10px 12px; border-left: 3px solid {self._accent}; background: #181825; border-top-left-radius: 8px; border-top-right-radius: 8px; }} """) if self._collapsible: self._header.setCursor(Qt.CursorShape.PointingHandCursor) self._header.installEventFilter(self) layout.addWidget(self._header) # Filter bar (only for collapsible columns) self._filter_bar = None if self._collapsible: self._filter_bar = QWidget() self._filter_bar.setStyleSheet("background: #181825;") filter_layout = QHBoxLayout(self._filter_bar) filter_layout.setContentsMargins(12, 0, 12, 6) filter_layout.setSpacing(4) self._filter_buttons: dict[int | None, QPushButton] = {} for label, days in [("7d", 7), ("30d", 30), ("All", None)]: btn = QPushButton(label) btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.setFixedHeight(22) btn.clicked.connect(lambda _checked, d=days: self._set_filter(d)) filter_layout.addWidget(btn) self._filter_buttons[days] = btn filter_layout.addStretch() layout.addWidget(self._filter_bar) self._filter_bar.setVisible(False) self._update_filter_styles() # Scroll area for cards self._scroll = QScrollArea() self._scroll.setObjectName("columnScroll") self._scroll.setWidgetResizable(True) self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self._scroll.setStyleSheet(""" #columnScroll { background: #181825; border: none; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; } QScrollBar:vertical { background: #181825; width: 6px; margin: 0; } QScrollBar::handle:vertical { background: #45475a; border-radius: 3px; min-height: 20px; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } """) self._card_container = QWidget() self._card_layout = QVBoxLayout(self._card_container) self._card_layout.setContentsMargins(6, 6, 6, 6) self._card_layout.setSpacing(6) self._card_layout.addStretch() self._scroll.setWidget(self._card_container) layout.addWidget(self._scroll) # Scroll area stays visible even when collapsed (shows empty dark bg) # -- Collapse / filter logic ------------------------------------------ def eventFilter(self, obj, event) -> bool: if obj is self._header and event.type() == event.Type.MouseButtonRelease: self._toggle_collapsed() return True return super().eventFilter(obj, event) def _toggle_collapsed(self) -> None: self._collapsed = not self._collapsed if self._filter_bar is not None: self._filter_bar.setVisible(not self._collapsed) self._update_filter_styles() self._update_header() self._render_cards() def _set_filter(self, days: int | None) -> None: self._filter_days = days self._update_filter_styles() self._render_cards() self._update_header() def _update_filter_styles(self) -> None: if self._filter_bar is None: return for days, btn in self._filter_buttons.items(): if days == self._filter_days: btn.setStyleSheet(f""" background: {self._accent}; color: #1e1e2e; border: none; border-radius: 4px; padding: 2px 8px; font-size: 11px; font-weight: bold; """) else: btn.setStyleSheet(""" background: #45475a; color: #bac2de; border: none; border-radius: 4px; padding: 2px 8px; font-size: 11px; """) def _update_header(self) -> None: total = len(self._all_entries) if not self._collapsible: self._header.setText(f"{self._name} ({total})") return if self._collapsed: self._header.setText(f"\u25b8 {self._name} ({total})") else: filtered = self._filtered_entries() if len(filtered) < total: self._header.setText( f"\u25be {self._name} ({len(filtered)} of {total})" ) else: self._header.setText(f"\u25be {self._name} ({total})") def _filtered_entries(self) -> list[tuple[Path, Entry]]: if self._filter_days is None: return self._all_entries cutoff = datetime.now() - timedelta(days=self._filter_days) return [(fp, e) for fp, e in self._all_entries if e.timestamp >= cutoff] # -- Public API ------------------------------------------------------- @property def status(self) -> EntryStatus: return self._status def set_entries( self, entries: list[tuple[Path, Entry]], on_status_change: callable, ) -> None: """Replace all cards in this column.""" self._all_entries = entries self._on_status_change = on_status_change self._update_header() self._render_cards() def _render_cards(self) -> None: """Clear and rebuild card widgets, applying date filter if collapsible.""" # Clear existing cards (keep the stretch at the end) while self._card_layout.count() > 1: item = self._card_layout.takeAt(0) if item.widget(): item.widget().deleteLater() if self._collapsed: return entries = self._filtered_entries() if self._collapsible else self._all_entries for filepath, entry in entries: card = EntryCard(filepath, entry, self._on_status_change, self) self._card_layout.insertWidget(self._card_layout.count() - 1, card) class BoardWindow(QWidget): """Kanban board window for managing memory entries.""" def __init__(self, config: Config): super().__init__() self._config = config self._columns: list[KanbanColumn] = [] self._debounce_timer = QTimer(self) self._debounce_timer.setSingleShot(True) self._debounce_timer.setInterval(300) self._debounce_timer.timeout.connect(self._refresh) self._setup_ui() self._setup_watcher() def _setup_ui(self) -> None: self.setWindowTitle("My Memory - Board") self.resize(900, 600) self.setMinimumSize(700, 400) self.setStyleSheet(self._stylesheet()) layout = QVBoxLayout(self) layout.setContentsMargins(16, 16, 16, 16) layout.setSpacing(12) # Header header = QLabel("My Memory") header.setObjectName("boardHeader") layout.addWidget(header) # Columns columns_layout = QHBoxLayout() columns_layout.setSpacing(10) for name, status, accent in _COLUMNS: col = KanbanColumn( name, status, accent, collapsible=(status == EntryStatus.COMPLETE), ) col.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self._columns.append(col) columns_layout.addWidget(col, stretch=1) layout.addLayout(columns_layout, stretch=1) def _setup_watcher(self) -> None: from PySide6.QtCore import QFileSystemWatcher self._watcher = QFileSystemWatcher(self) entries_dir = self._config.entries_dir if entries_dir.exists(): self._watcher.addPath(str(entries_dir)) for subdir in entries_dir.iterdir(): if subdir.is_dir(): self._watcher.addPath(str(subdir)) self._watcher.directoryChanged.connect(self._on_directory_changed) def _on_directory_changed(self, _path: str) -> None: # Re-watch new date directories that may have appeared entries_dir = self._config.entries_dir if entries_dir.exists(): current = set(self._watcher.directories()) for subdir in entries_dir.iterdir(): if subdir.is_dir() and str(subdir) not in current: self._watcher.addPath(str(subdir)) self._debounce_timer.start() def show_board(self) -> None: """Show the board window and refresh entries.""" self._refresh() self.show() self.raise_() self.activateWindow() def _refresh(self) -> None: """Reload all entries from disk and repopulate columns.""" all_entries = load_all_entries(self._config) grouped: dict[EntryStatus, list[tuple[Path, Entry]]] = { s: [] for s in EntryStatus } for filepath, entry in all_entries: grouped[entry.status].append((filepath, entry)) for col in self._columns: col.set_entries(grouped[col.status], self._refresh) @staticmethod def _stylesheet() -> str: return """ BoardWindow { background: #1e1e2e; } #boardHeader { color: #cdd6f4; font-size: 18px; font-weight: bold; } #entryCard { background: #313244; border: 1px solid #45475a; border-radius: 8px; } #entryCard:hover { border-color: #585b70; } #cardContent { color: #cdd6f4; font-size: 13px; line-height: 1.3; } #cardMeta { color: #6c7086; font-size: 11px; } #moveBtn { background: #45475a; color: #bac2de; border: none; border-radius: 4px; padding: 3px 8px; font-size: 11px; } #moveBtn:hover { background: #585b70; color: #cdd6f4; } """