my-memory/src/my_memory/board_window.py
Cal Corum 195cd443cb feat: initial commit — voice/text memory capture with kanban board
PySide6 app for capturing quick memories via voice or text, organized
on a kanban board (On Docket → In Progress → Complete). Complete column
is collapsible with 7d/30d/All date filters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:55:21 -06:00

505 lines
17 KiB
Python

"""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;
}
"""