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>
505 lines
17 KiB
Python
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;
|
|
}
|
|
"""
|