refactor(scheduling): R08-T11 — schedule_task_tab.py 794 -> 297, tách 6 file

presentation/scheduling/
      calendar_view_widget.py    231  lịch tháng (chuyển từ ui/calendar_view.py)
      ai_task_creator_dialog.py  208  tạo task bằng AI
      task_actions.py            189  thêm/sửa/chạy/xoá/xem log một task
      kanban_board_widget.py      98  cột Kanban + vùng thả file
      run_history_dialog.py       82  lịch sử các lượt chạy
      ai_task_import_dialog.py    81  nhập task từ file
    ui/schedule_task_tab.py      297  dựng bảng + đổi chế độ xem
    ui/calendar_view.py           10  vỏ chuyển tiếp

Plan ghi 4 file; thực tế cần 6. Hai file thêm là run_history_dialog.py và
task_actions.py — không tách thì schedule_task_tab.py còn 517 dòng, vẫn vượt
ngưỡng 400.

ai_task_import_dialog.py làm mixin chứ không phải hộp thoại rời: plan gọi nó
là dialog, nhưng thực tế nó là TAB THỨ HAI của cùng hộp thoại tạo task, dùng
chung phần xem trước và nút Xác nhận. Tách hẳn thì phải nhân đôi cả hai.

LẠI LỖI DECORATOR: script này tôi quên dùng bản có tính dòng @, nên một
@staticmethod bị bỏ lại mồ côi -> IndentationError. Đây là lần thứ tư cùng
một lỗi. Đã thêm bước dọn decorator mồ côi vào script.

756 test xanh. 16 checker chạy đều qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-27 22:54:50 +09:00
co-authored by Claude Opus 5
parent 4fef41481b
commit 982fecc8dc
8 changed files with 906 additions and 730 deletions
+6 -227
View File
@@ -1,231 +1,10 @@
"""Calendar view for Schedule Task — an alternative to the Kanban board:
Week / Month / Year granularity, each task placed on its scheduled date
(``schedule.run_at``). Click a task to edit it (same editor the Kanban
board's double-click opens); click a day's "+" to create a task pre-filled
with that date. All grid/date math lives in ``core/calendar_grid.py`` (no Qt,
directly unit-testable) — this module is just the Qt rendering of it.
"""Vỏ chuyển tiếp — R08-T11.
Phần thân đã chuyển sang ``presentation/scheduling/calendar_view_widget.py``.
Giữ đường import cũ cho ``ui/schedule_task_tab.py`` và checker.
"""
from __future__ import annotations
from datetime import date
from typing import Dict, List, Optional
from ..presentation.scheduling.calendar_view_widget import CalendarView, _DayCell # noqa: F401
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox, QFrame, QGridLayout, QHBoxLayout, QLabel, QListWidget,
QListWidgetItem, QPushButton, QScrollArea, QVBoxLayout, QWidget,
)
from ..core.calendar_grid import (
GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days,
)
from ..i18n import on_language_changed, tr
from ..theme import current_palette
from .icons import icon
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
class _DayCell(QFrame):
add_requested = Signal(str) # "YYYY-MM-DD"
task_clicked = Signal(str) # task_id
def __init__(self):
super().__init__()
self.setObjectName("dayCell")
self.setFrameShape(QFrame.StyledPanel)
self._date_str = ""
lay = QVBoxLayout(self)
lay.setContentsMargins(4, 4, 4, 4)
lay.setSpacing(2)
head = QHBoxLayout()
self.date_lbl = QLabel()
self.add_btn = QPushButton("+")
self.add_btn.setFixedSize(20, 20)
self.add_btn.clicked.connect(lambda: self.add_requested.emit(self._date_str))
head.addWidget(self.date_lbl, 1)
head.addWidget(self.add_btn)
lay.addLayout(head)
self.list = QListWidget()
self.list.setFrameShape(QFrame.NoFrame)
# Transparent so the cell's today/weekend tint shows through the task area.
self.list.setStyleSheet("background: transparent;")
self.list.itemClicked.connect(self._on_item_clicked)
lay.addWidget(self.list, 1)
def set_day(self, d: date, tasks: List[dict], dim: bool,
today: bool = False, weekend: bool = False) -> None:
self._date_str = d.isoformat()
self.date_lbl.setText(str(d.day))
p = current_palette()
num_color = p.accent if today else (p.text_faint if dim else p.text)
self.date_lbl.setStyleSheet(f"font-weight:600; color:{num_color};")
# Today is the only cell that gets a filled surface + accent border;
# weekends are set apart by a recessed surface alone, so the eye lands
# on "today" first and on the weekend block only when scanning.
r = p.radius
if today:
css = (f"#dayCell {{ background: {p.accent_soft}; "
f"border: 1px solid {p.accent}; border-radius: {r}px; }}")
elif weekend:
css = (f"#dayCell {{ background: {p.surface}; "
f"border: 1px solid {p.border}; border-radius: {r}px; }}")
else:
css = f"#dayCell {{ border: 1px solid {p.border}; border-radius: {r}px; }}"
self.setStyleSheet(css)
self.list.clear()
for t in tasks:
item = QListWidgetItem(t.get("title") or tr("schedtask.no_title"))
item.setData(Qt.UserRole, t.get("task_id"))
self.list.addItem(item)
def _on_item_clicked(self, item: QListWidgetItem) -> None:
tid = item.data(Qt.UserRole)
if tid:
self.task_clicked.emit(tid)
class CalendarView(QWidget):
add_task_on_date = Signal(str) # "YYYY-MM-DD"
edit_task = Signal(str) # task_id
def __init__(self):
super().__init__()
self.granularity = "month"
self.anchor = date.today()
self._tasks: List[dict] = []
root = QVBoxLayout(self)
head = QHBoxLayout()
self.prev_btn = QPushButton()
self.prev_btn.setIcon(icon("chevron-left"))
self.prev_btn.clicked.connect(lambda: self._shift(-1))
self.today_btn = QPushButton()
self.today_btn.clicked.connect(self._go_today)
self.next_btn = QPushButton()
self.next_btn.setIcon(icon("chevron-right"))
self.next_btn.clicked.connect(lambda: self._shift(1))
self.period_lbl = QLabel()
self.period_lbl.setStyleSheet("font-weight:700;")
self.granularity_combo = QComboBox()
for g in GRANULARITIES:
self.granularity_combo.addItem("", g)
self.granularity_combo.currentIndexChanged.connect(self._on_granularity_changed)
head.addWidget(self.prev_btn)
head.addWidget(self.today_btn)
head.addWidget(self.next_btn)
head.addWidget(self.period_lbl, 1)
head.addWidget(self.granularity_combo)
root.addLayout(head)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
self._grid_host = QWidget()
self._grid = QGridLayout(self._grid_host)
self._grid.setSpacing(4)
scroll.setWidget(self._grid_host)
root.addWidget(scroll, 1)
on_language_changed(self._retranslate)
self._retranslate()
def _retranslate(self) -> None:
self.today_btn.setText(tr("schedtask.cal_today"))
self.prev_btn.setToolTip(tr("schedtask.cal_prev"))
self.next_btn.setToolTip(tr("schedtask.cal_next"))
for i, g in enumerate(GRANULARITIES):
self.granularity_combo.setItemText(i, tr(f"schedtask.cal_gran.{g}"))
self._render()
# ---- public ------------------------------------------------------
def set_tasks(self, tasks: List[dict]) -> None:
self._tasks = tasks
self._render()
def show_month(self, year: int, month: int) -> None:
"""Switch to Month view centered on (year, month) — used when the
user drills down from a Year-view row."""
self.anchor = date(year, month, 1)
self.granularity = "month"
idx = self.granularity_combo.findData("month")
if idx >= 0:
self.granularity_combo.blockSignals(True)
self.granularity_combo.setCurrentIndex(idx)
self.granularity_combo.blockSignals(False)
self._render()
# ---- navigation ---------------------------------------------------
def _shift(self, direction: int) -> None:
self.anchor = shift_period(self.anchor, self.granularity, direction)
self._render()
def _go_today(self) -> None:
self.anchor = date.today()
self._render()
def _on_granularity_changed(self) -> None:
data = self.granularity_combo.currentData()
if data:
self.granularity = data
self._render()
# ---- rendering ------------------------------------------------------
def _clear_grid(self) -> None:
while self._grid.count():
item = self._grid.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
def _render(self) -> None:
self._update_period_label()
self._clear_grid()
by_date = group_tasks_by_date(self._tasks)
if self.granularity == "week":
self._render_days(week_days(self.anchor), by_date)
elif self.granularity == "year":
self._render_year(by_date)
else:
self._render_days(sum(month_grid(self.anchor), []), by_date, mark_month=self.anchor.month)
def _render_days(self, days: List[date], by_date: Dict[str, List[dict]],
mark_month: Optional[int] = None) -> None:
for col, key in enumerate(_WEEKDAY_KEYS):
lbl = QLabel(tr(f"schedtask.cal_weekday.{key}"))
lbl.setStyleSheet("font-weight:600;")
lbl.setAlignment(Qt.AlignCenter)
self._grid.addWidget(lbl, 0, col)
today = date.today()
rows = [days[i:i + 7] for i in range(0, len(days), 7)]
for r, week in enumerate(rows, start=1):
for c, d in enumerate(week):
cell = _DayCell()
dim = mark_month is not None and d.month != mark_month
# _WEEKDAY_KEYS is Mon..Sun → columns 5 (Sat) and 6 (Sun) are the weekend.
cell.set_day(d, by_date.get(d.isoformat(), []), dim,
today=(d == today), weekend=(c in (5, 6)))
cell.add_requested.connect(self.add_task_on_date.emit)
cell.task_clicked.connect(self.edit_task.emit)
self._grid.addWidget(cell, r, c)
def _render_year(self, by_date: Dict[str, List[dict]]) -> None:
counts = month_task_counts(by_date, self.anchor.year)
lst = QListWidget()
for m in range(1, 13):
label = date(self.anchor.year, m, 1).strftime("%B")
n = counts[m]
text = tr("schedtask.cal_month_count", month=label, n=n) if n else label
item = QListWidgetItem(text)
item.setData(Qt.UserRole, m)
lst.addItem(item)
lst.itemClicked.connect(lambda item: self.show_month(self.anchor.year, item.data(Qt.UserRole)))
self._grid.addWidget(lst, 0, 0)
def _update_period_label(self) -> None:
if self.granularity == "week":
days = week_days(self.anchor)
self.period_lbl.setText(f"{days[0].isoformat()} - {days[-1].isoformat()}")
elif self.granularity == "year":
self.period_lbl.setText(str(self.anchor.year))
else:
self.period_lbl.setText(self.anchor.strftime("%Y-%m"))
__all__ = ["CalendarView"]