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:
co-authored by
Claude Opus 5
parent
4fef41481b
commit
982fecc8dc
@@ -0,0 +1,231 @@
|
||||
"""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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
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 ...ui.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"))
|
||||
Reference in New Issue
Block a user