Feature/delta team/epic r04 (#7)
CI / test (push) Canceled after 0s

## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-08-31 05:15:13 +00:00
committed by gitea-admin
co-authored by anhtnm1 huongltt35 Nam Pham Dinh Thanh vudt15 Hiep Ha Van lamhv7
parent 86c27e2e79
commit f9f6bc01fd
496 changed files with 68421 additions and 19688 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Schedule Task screen, split into single-responsibility widgets (R08-T11):
``kanban_board_widget``, ``calendar_view_widget``, ``ai_task_creator_dialog``,
``ai_task_import_dialog``, assembled by the ``schedule_task_tab`` shell."""
@@ -0,0 +1,212 @@
"""AiTaskCreatorDialog — "AI Create Task" (R08-T11, extracted from
``ui/schedule_task_tab.py``'s ``_AiCreateDialog``, lines 579-641/722-794 of
the original 795-line file).
Still one dialog with two tabs (AI-gen, then Import — the latter is
:class:`~presentation.scheduling.ai_task_import_dialog.ImportTaskPanel`,
embedded here rather than duplicated): the physical file split matches
``docs/refactor/Feature_Architecture_Proposal.md``'s R08-T11 breakdown, the
user-visible dialog is unchanged. ``_confirm`` still uses "whichever tab
produced a task list most recently" (mirroring the original class's shared
``self._planned`` attribute) — the AI-gen tab sets it on completion, the
Import tab reports it through :attr:`ImportTaskPanel.tasks_changed`.
AI generation goes through
``application/scheduling/ai_task_planner_service.py::AiTaskPlannerService``
(R07-T05) instead of ``core.ai_task_planner.plan_tasks`` directly.
"""
from __future__ import annotations
from typing import List, Optional
from PySide6.QtWidgets import (
QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit,
QPlainTextEdit, QPushButton, QTabWidget, QVBoxLayout, QWidget,
)
from cowork_local.application.scheduling.ai_task_planner_service import (
AiTaskPlannerService,
)
from cowork_local.core.projects import list_projects
from cowork_local.core.worker import AgentWorker
from cowork_local.i18n import tr
from cowork_local.presentation.scheduling.ai_task_import_dialog import ImportTaskPanel
from cowork_local.state import AppContext
from cowork_local.ui.icons import icon
class AiTaskCreatorDialog(QDialog):
"""Create tasks two ways, one tab each (both preview first — nothing is
saved until the user confirms): ✨ AI gen from a natural-language
description, or 📥 Import from a filled Excel/CSV/JSON file."""
def __init__(self, ctx: AppContext, parent=None):
"""Hộp thoại tạo task bằng AI hoặc nhập từ tệp.
Nhớ tab nào đang cấp danh sách task cho nút Đồng ý — hai tab cùng sinh ra
danh sách, không phân biệt thì bấm Đồng ý có thể tạo nhầm danh sách của tab
kia.
"""
super().__init__(parent)
self.ctx = ctx
self._planner = AiTaskPlannerService(provider_factory=ctx.build_active_provider)
self.created_tasks: List[dict] = []
self._ai_planned: List[dict] = []
# Which tab produced the task list currently backing the Ok button —
# mirrors the original single-class dialog's shared `self._planned`
# attribute, where whichever of _on_planned()/_load_import_file()
# ran LAST (regardless of which tab is currently showing) won.
self._active_source = "ai"
self._worker: Optional[AgentWorker] = None
self.setWindowTitle(tr("schedtask.ai_btn"))
self.resize(600, 520)
root = QVBoxLayout(self)
ws_row = QHBoxLayout()
ws_row.addWidget(QLabel(tr("schedtask.f_workspace")))
self.workspace_combo = QComboBox()
self.workspace_combo.addItem(tr("schedtask.no_workspace"), "")
for p in list_projects():
self.workspace_combo.addItem(p.name, p.project_id)
self.workspace_combo.setToolTip(tr("schedtask.hint_workspace"))
ws_row.addWidget(self.workspace_combo, 1)
root.addLayout(ws_row)
self.tabs = QTabWidget()
root.addWidget(self.tabs, 1)
self.tabs.addTab(self._build_ai_gen_page(), tr("schedtask.tab_ai"))
self.import_panel = ImportTaskPanel(self._planner)
self.import_panel.tasks_changed.connect(self._on_import_tasks_changed)
self.tabs.addTab(self.import_panel, tr("schedtask.tab_import"))
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
self.buttons.accepted.connect(self._confirm)
self.buttons.rejected.connect(self.reject)
root.addWidget(self.buttons)
# ---- AI-gen tab -------------------------------------------------------
def _build_ai_gen_page(self) -> QWidget:
"""Dựng tab "Sinh bằng AI": ô mô tả, tệp/link đính kèm và nút sinh."""
ai_page = QWidget()
al = QVBoxLayout(ai_page)
al.addWidget(QLabel(tr("schedtask.ai_desc_label")))
self.desc_edit = QPlainTextEdit()
self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph"))
self.desc_edit.setMaximumHeight(110)
al.addWidget(self.desc_edit)
# Attachments (files + links) — merged into every task this generates,
# AND into the planning prompt so the AI knows they exist.
attach_row = QHBoxLayout()
self.ai_files_edit = QLineEdit()
self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder"))
ai_pick_btn = QPushButton(tr("schedtask.pick_files"))
ai_pick_btn.setIcon(icon("folder"))
ai_pick_btn.clicked.connect(self._ai_pick_files)
attach_row.addWidget(self.ai_files_edit, 1)
attach_row.addWidget(ai_pick_btn)
al.addWidget(QLabel(tr("schedtask.f_files")))
al.addLayout(attach_row)
self.ai_links_edit = QLineEdit()
self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder"))
al.addWidget(QLabel(tr("schedtask.f_links")))
al.addWidget(self.ai_links_edit)
self.gen_btn = QPushButton(tr("schedtask.ai_generate"))
self.gen_btn.setIcon(icon("sparkle"))
self.gen_btn.setObjectName("primary")
self.gen_btn.clicked.connect(self._generate)
al.addWidget(self.gen_btn)
al.addWidget(QLabel(tr("schedtask.ai_preview_label")))
self.preview = QPlainTextEdit()
self.preview.setReadOnly(True)
al.addWidget(self.preview, 1)
return ai_page
def _ai_pick_files(self) -> None:
"""Chọn tệp đính kèm làm ngữ cảnh cho AI lập kế hoạch."""
from PySide6.QtWidgets import QFileDialog
files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
if files:
existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()]
self.ai_files_edit.setText("; ".join(existing + files))
def _attached_files(self) -> List[str]:
"""Danh sách đường dẫn tệp đã nhập, tách theo dấu ``;``."""
return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()]
def _attached_links(self) -> List[str]:
"""Danh sách link đã nhập, tách theo dấu ``;``."""
return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()]
def _generate(self) -> None:
"""Nhờ AI tách mô tả thành nhiều task; đang chạy dở thì bỏ qua."""
description = self.desc_edit.toPlainText().strip()
if not description or self._worker is not None:
return
files, links = self._attached_files(), self._attached_links()
self.gen_btn.setEnabled(False)
self.gen_btn.setText(tr("schedtask.ai_generating"))
def job(worker: AgentWorker):
"""Chạy nền: ghép mô tả với danh sách tệp/link rồi gọi bộ lập kế hoạch."""
full_desc = description
if files or links:
attach_note = "; ".join(files + links)
full_desc += f"\n\n(Attached references available: {attach_note})"
planned = self._planner.plan(
full_desc, file_paths=files, links=links, cancel=worker.is_cancelled)
return {"tasks": planned}
w = AgentWorker(job)
w.finished_ok.connect(self._on_planned)
w.failed.connect(self._on_failed)
self._worker = w
w.start()
def _on_planned(self, result: dict) -> None:
"""Có kết quả: đổ danh sách task đề xuất ra bảng để người dùng xem lại."""
self._worker = None
self.gen_btn.setEnabled(True)
self.gen_btn.setText(tr("schedtask.ai_generate"))
self._ai_planned = result.get("tasks") or []
self._active_source = "ai"
lines = []
for i, t in enumerate(self._ai_planned, 1):
sched = t.get("schedule", {})
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
dep = t.get("dependency", {})
chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else ""
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n"
f" {t.get('description', '')[:150]}")
self.preview.setPlainText("\n\n".join(lines))
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._ai_planned))
def _on_failed(self, err: str) -> None:
"""Lập kế hoạch lỗi: trả nút về trạng thái bấm được và hiện lý do."""
self._worker = None
self.gen_btn.setEnabled(True)
self.gen_btn.setText(tr("schedtask.ai_generate"))
self.preview.setPlainText(str(err))
# ---- Import tab ---------------------------------------------------------
def _on_import_tasks_changed(self, has_tasks: bool) -> None:
"""Tab Nhập có task thì chuyển nguồn dữ liệu sang đó và mở nút OK."""
if has_tasks:
self._active_source = "import"
self.buttons.button(QDialogButtonBox.Ok).setEnabled(has_tasks)
# ---- confirm ------------------------------------------------------------
def _confirm(self) -> None:
"""Chốt: lấy task từ nguồn đang hoạt động (AI hoặc Nhập) và gắn project cho chúng."""
planned = self.import_panel.planned if self._active_source == "import" else self._ai_planned
project_id = self.workspace_combo.currentData() or ""
for t in planned:
t["project_id"] = project_id
self.created_tasks = planned
self.accept()
__all__ = ["AiTaskCreatorDialog"]
@@ -0,0 +1,157 @@
"""Import-from-file tab content for AI Create Task (R08-T11, extracted from
``ui/schedule_task_tab.py``'s ``_AiCreateDialog`` — the Import tab + its
``_DropZone``, lines 551-576/643-665/674-720 of the original file).
:class:`ImportTaskPanel` is a plain ``QWidget`` (not its own dialog) so
``ai_task_creator_dialog.py`` can embed it as one tab of the single AI-create
dialog the user sees — the two files are a code split, not a UX split; there
is still one dialog with two tabs, exactly as before.
"""
from __future__ import annotations
from pathlib import Path
from typing import List
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QHBoxLayout, QLabel, QMessageBox, QPlainTextEdit, QPushButton,
QVBoxLayout, QWidget,
)
from cowork_local.application.scheduling.ai_task_planner_service import (
AiTaskPlannerService,
)
from cowork_local.i18n import tr
from cowork_local.theme import current_palette
from cowork_local.ui.icons import icon
from cowork_local.ui.osutil import open_path
class _DropZone(QLabel):
"""Drag-an-.xlsx-here area for the Import tab."""
file_dropped = Signal(str)
def __init__(self):
"""Vùng kéo–thả tệp danh sách task."""
super().__init__()
from PySide6.QtCore import Qt
self.setAlignment(Qt.AlignCenter)
self.setMinimumHeight(70)
_p = current_palette()
self.setStyleSheet(
f"QLabel {{ border: 1px dashed {_p.border_strong};"
f" border-radius: {_p.radius_lg}px;"
f" color: {_p.text_muted}; padding: 10px; }}")
self.setAcceptDrops(True)
def dragEnterEvent(self, event): # noqa: N802
"""Chỉ nhận tệp có đuôi hợp lệ."""
urls = event.mimeData().urls()
if urls and urls[0].toLocalFile().lower().endswith(
(".xlsx", ".xlsm", ".xls", ".csv", ".json")):
event.acceptProposedAction()
def dropEvent(self, event): # noqa: N802
"""Thả tệp: phát đường dẫn lên để panel đọc và dựng danh sách task."""
urls = event.mimeData().urls()
if urls:
self.file_dropped.emit(urls[0].toLocalFile())
class ImportTaskPanel(QWidget):
"""Pick/drag an Excel/CSV/JSON file, preview the tasks it maps to, and
hold that NOT-yet-saved list — the dialog reads :attr:`planned` when the
user confirms.
Args:
planner: an ``AiTaskPlannerService`` — ``import_file`` is called
through it (R07-T05) rather than ``core.task_import`` directly.
"""
tasks_changed = Signal(bool) # True when the current preview has >=1 valid task
def __init__(self, planner: AiTaskPlannerService, parent=None):
"""Panel nhập task từ tệp: xuất mẫu, chọn tệp, xem trước rồi mới tạo."""
super().__init__(parent)
self._planner = planner
self.planned: List[dict] = []
il = QVBoxLayout(self)
tpl_btn = QPushButton(tr("schedtask.export_template_btn"))
tpl_btn.setIcon(icon("upload"))
tpl_btn.clicked.connect(self._export_template)
il.addWidget(tpl_btn)
pick_row = QHBoxLayout()
pick_btn = QPushButton(tr("schedtask.import_pick_btn"))
pick_btn.setIcon(icon("folder"))
pick_btn.clicked.connect(self._pick_import_file)
pick_row.addWidget(pick_btn)
pick_row.addStretch(1)
il.addLayout(pick_row)
self.drop_zone = _DropZone()
self.drop_zone.setText(tr("schedtask.drop_hint"))
self.drop_zone.file_dropped.connect(self._load_import_file)
il.addWidget(self.drop_zone)
il.addWidget(QLabel(tr("schedtask.ai_preview_label")))
self.preview = QPlainTextEdit()
self.preview.setReadOnly(True)
il.addWidget(self.preview, 1)
def _export_template(self) -> None:
"""Xuất file mẫu Excel để người dùng điền danh sách task rồi nhập lại."""
from PySide6.QtWidgets import QFileDialog
from cowork_local.core.task_excel import export_template
path, _ = QFileDialog.getSaveFileName(
self, tr("schedtask.export_template_btn"),
"cowork_tasks_template.xlsx", "Excel (*.xlsx)")
if not path:
return
try:
export_template(path)
open_path(str(Path(path).parent))
except Exception as exc: # noqa: BLE001
QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc))
def _pick_import_file(self) -> None:
"""Chọn file danh sách task để nhập."""
from PySide6.QtWidgets import QFileDialog
from cowork_local.core.task_import import IMPORT_FILTER
path, _ = QFileDialog.getOpenFileName(
self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER)
if path:
self._load_import_file(path)
def _load_import_file(self, path: str) -> None:
"""Đọc file và dựng danh sách task xem trước; file sai khuôn thì hiện lý do cụ
thể chứ không im lặng bỏ qua.
"""
try:
self.planned = self._planner.import_file(path)
except ValueError as exc:
# Same as the original single-class dialog: a bad file leaves
# whatever was previously loaded in `planned` untouched (only the
# preview text and the Ok button reflect the failure) rather than
# discarding a prior successful load.
self.preview.setPlainText(str(exc))
self.tasks_changed.emit(False)
return
by_id = {t["task_id"]: t["title"] for t in self.planned}
lines = []
for i, t in enumerate(self.planned, 1):
sched = t.get("schedule", {})
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
deps = t.get("dependency", {}).get("depends_on") or []
dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else ""
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}")
self.preview.setPlainText("\n\n".join(lines))
self.tasks_changed.emit(bool(self.planned))
__all__ = ["ImportTaskPanel"]
@@ -0,0 +1,268 @@
"""Calendar view for Schedule Task — an alternative to the Kanban board
(R08-T11, relocated from ``ui/calendar_view.py`` with no logic changes):
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 cowork_local.core.calendar_grid import (
GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days,
)
from cowork_local.i18n import on_language_changed, tr
from cowork_local.theme import current_palette
from cowork_local.ui.icons import icon
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
class _DayCell(QFrame):
"""Một ô ngày trên lưới lịch: số ngày, nút thêm task, và danh sách task của ngày đó."""
add_requested = Signal(str) # "YYYY-MM-DD"
task_clicked = Signal(str) # task_id
def __init__(self):
"""Một ô ngày trên lịch, chứa các task đến hạn hôm đó."""
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:
"""Vẽ lại ô cho một ngày cụ thể.
Chỉ "hôm nay" được nền tô đậm kèm viền nhấn; cuối tuần chỉ đổi nền chìm.
Nhờ vậy mắt bắt vào hôm nay trước, còn khối cuối tuần chỉ hiện ra khi
người dùng quét cả tháng. ``dim`` làm mờ ngày thuộc tháng khác.
"""
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:
"""Bấm vào một task trong ô: phát id lên để lớp trên mở trình sửa."""
tid = item.data(Qt.UserRole)
if tid:
self.task_clicked.emit(tid)
class CalendarView(QWidget):
"""Khung nhìn Lịch của màn Lịch trình: xem theo tuần, tháng hoặc năm.
Widget này chỉ VẼ và phát tín hiệu; việc tạo/sửa task do vỏ
``ScheduleTaskTab`` làm, vì Kanban cũng cần đúng hành vi đó.
"""
add_task_on_date = Signal(str) # "YYYY-MM-DD"
edit_task = Signal(str) # task_id
def __init__(self):
"""Lịch xem task theo tháng/tuần/ngày, neo vào hôm nay."""
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:
"""Áp lại chữ theo ngôn ngữ đang chọn rồi vẽ lại lưới."""
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:
"""Nhận danh sách task mới và vẽ lại toàn bộ khung nhìn."""
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:
"""Lùi/tiến một kỳ theo đúng độ mịn đang chọn (tuần, tháng hay năm)."""
self.anchor = shift_period(self.anchor, self.granularity, direction)
self._render()
def _go_today(self) -> None:
"""Nhảy về kỳ chứa ngày hôm nay."""
self.anchor = date.today()
self._render()
def _on_granularity_changed(self) -> None:
"""Đổi độ mịn tuần/tháng/năm và vẽ lại."""
data = self.granularity_combo.currentData()
if data:
self.granularity = data
self._render()
# ---- rendering ------------------------------------------------------
def _clear_grid(self) -> None:
"""Xoá sạch lưới trước khi vẽ lại.
Dùng ``deleteLater`` chứ không bỏ tham chiếu: widget Qt còn đang trong
hàng đợi sự kiện, xoá ngay có thể làm vỡ lúc đang xử lý sự kiện chuột.
"""
while self._grid.count():
item = self._grid.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
def _render(self) -> None:
"""Vẽ lại lưới theo độ mịn đang chọn: tuần, tháng (kèm ngày tràn) hoặc năm."""
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:
"""Vẽ lưới 7 cột: hàng đầu là tên thứ, các hàng sau là ô ngày.
``mark_month`` là tháng "chính": ngày thuộc tháng khác bị làm mờ. Cột 5 và
6 là thứ Bảy và Chủ nhật vì ``_WEEKDAY_KEYS`` bắt đầu từ thứ Hai.
"""
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:
"""Xem theo năm: danh sách 12 tháng kèm số task, bấm vào là mở tháng đó."""
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:
"""Cập nhật nhãn kỳ đang xem: khoảng ngày (tuần), năm, hoặc YYYY-MM (tháng)."""
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"]
@@ -0,0 +1,396 @@
"""Kanban board for Schedule Task (R08-T11, extracted from
``ui/schedule_task_tab.py``'s ``ScheduleTaskTab`` — Kanban rendering +
drag-drop + row actions, lines 41-79/222-300/302-484/496-548 of the original
795-line file).
Owns the 7-lane board itself. What used to be plain module-function calls
into ``core/tasks.py`` (``duplicate_task``, ``taskrepo.save_task``,
``taskrepo.delete_task``, ...) and ``self.scheduler.run_now(...)`` inline are
now calls into
``application/scheduling/task_application_service.py::TaskApplicationService``
(R07-T04) — the drag-drop business rules (dropping on Running/Done/Scheduled)
in particular used to be ~30 lines of if/elif inside a Qt slot; now it's
``TaskApplicationService.move_to_status`` plus a few branches on its result.
Task EDITING (opening ``TaskEditorDialog``) is deliberately NOT owned here —
``CalendarView`` needs the exact same "open the editor for this task id"
behaviour for its own click handler, so it stays a shell-level concern
(``schedule_task_tab.py``) both widgets request via a signal, instead of
being duplicated in two places.
"""
from __future__ import annotations
from typing import Dict, List, Optional
from PySide6.QtCore import QEvent, Qt, Signal
from PySide6.QtWidgets import (
QAbstractItemView, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMenu, QMessageBox, QScrollArea, QVBoxLayout, QWidget,
)
from cowork_local.application.scheduling.task_application_service import (
TaskApplicationService,
)
from cowork_local.core.tasks import STATUSES, chain_error, new_task
from cowork_local.i18n import tr
from cowork_local.infrastructure.persistence.json.task_repository_impl import (
TaskRepository,
)
from cowork_local.presentation.scheduling.run_history_dialog import RunHistoryDialog
from cowork_local.theme import current_palette
from cowork_local.ui.osutil import open_path
# Priority shown as a plain text tag (no colored-emoji squares). Only the
# elevated priorities get a visible marker; low/medium stay unmarked as before.
_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"}
class _KanbanColumn(QListWidget):
"""One status lane. Accepts drops from sibling columns; a drop means
'move this task to my status'."""
task_dropped = Signal(str, str) # task_id, new_status
def __init__(self, status: str):
"""Một cột Kanban ứng với một trạng thái task.
Cho chọn nhiều thẻ trong CÙNG một cột (Shift/Ctrl) để chuột phải xoá hàng
loạt, thay vì xoá từng cái.
"""
super().__init__()
self.status = status
self.setDragDropMode(QAbstractItemView.DragDrop)
self.setDefaultDropAction(Qt.MoveAction)
# Shift/Ctrl-click several cards in the SAME column, then right-click
# → "Delete N selected" to bulk-remove tasks instead of one at a time.
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.setWordWrap(True)
# Cards wrap, so there is never anything to reach by scrolling
# sideways — but QListWidget's own column hint runs 1-6px past the
# viewport; the board divides whatever width it has by seven instead
# (see KanbanBoardWidget._fit_lanes()).
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize
def dropEvent(self, event): # noqa: N802
"""Thả một thẻ từ lane khác sang: báo lên bảng để đổi trạng thái task."""
source = event.source()
if isinstance(source, _KanbanColumn) and source is not self:
item = source.currentItem()
tid = item.data(Qt.UserRole) if item else None
if tid:
event.acceptProposedAction()
self.task_dropped.emit(tid, self.status)
return
event.ignore()
class KanbanBoardWidget(QWidget):
"""The 7-lane board: Backlog / Scheduled / Running / Waiting Input /
Done / Failed / Paused. Cards drag between columns (dropping = changing
status via ``TaskApplicationService.move_to_status``), double-click and
the right-click menu request an edit via :attr:`edit_requested`.
Args:
ctx: ``AppContext`` — passed through to ``TaskEditorDialog`` callers
need it for, kept here only so callers don't have to fetch it
separately.
tasks_dir: ``None`` -> the app's default task-storage directory;
tests pass a ``tmp_path``.
scheduler: ``TaskScheduler`` (may be ``None`` — matches the original
widget's "no scheduler in tests" tolerance) used as the
``run_now`` dispatch source for the service.
service: inject a ready-made ``TaskApplicationService`` (tests); when
``None``, one is built from ``tasks_dir``/``scheduler``.
"""
status_message = Signal(str)
counts_changed = Signal(dict) # status -> count, for the shell's summary label
edit_requested = Signal(str) # task_id — shell opens TaskEditorDialog
_LANE_FLOOR_CH = 8 # roughly eight characters of a task title, plus padding
def __init__(self, ctx, tasks_dir=None, scheduler=None,
service: Optional[TaskApplicationService] = None, parent=None):
"""Bảng Kanban các task.
``service`` để None thì tự dựng một cái từ kho task; ``scheduler`` để None
thì bảng chỉ đọc/ghi task chứ không chạy được cái nào.
"""
super().__init__(parent)
self.ctx = ctx
self._tasks_dir = tasks_dir
self._repo = TaskRepository(tasks_dir)
self._service = service or TaskApplicationService(
self._repo, run_now=scheduler.run_now if scheduler is not None else None)
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
board = QWidget()
scroll.setWidget(board)
cols = QHBoxLayout(board)
cols.setSpacing(2)
self.columns: Dict[str, _KanbanColumn] = {}
self.column_headers: Dict[str, QLabel] = {}
for status in STATUSES:
box = QVBoxLayout()
box.setContentsMargins(0, 0, 0, 0)
box.setSpacing(2)
head = QLabel()
head.setStyleSheet("font-weight:600;")
col = _KanbanColumn(status)
col.setObjectName("kanbanLane")
col.task_dropped.connect(self._on_task_dropped)
col.itemDoubleClicked.connect(self._on_double_click)
col.setContextMenuPolicy(Qt.CustomContextMenu)
col.customContextMenuRequested.connect(
lambda pos, c=col: self._context_menu(c, pos))
box.addWidget(head)
box.addWidget(col, 1)
holder = QWidget()
holder.setLayout(box)
cols.addWidget(holder)
self.columns[status] = col
self.column_headers[status] = head
root.addWidget(scroll, 1)
self._board_scroll = scroll
scroll.viewport().installEventFilter(self)
def retranslate(self) -> None:
"""Áp lại tooltip của từng lane theo ngôn ngữ đang chọn."""
for status, col in self.columns.items():
col.setToolTip(tr(f"schedtask.col_tip.{status}"))
# ---- lane widths ------------------------------------------------------
def eventFilter(self, obj, event): # noqa: N802
"""Vùng cuộn đổi kích thước thì chia lại bề rộng cho 7 lane."""
if obj is self._board_scroll.viewport() and event.type() == QEvent.Resize:
self._fit_lanes()
return super().eventFilter(obj, event)
def _fit_lanes(self) -> None:
"""Chia bề rộng cho 7 lane sao cho tất cả vừa một màn.
Có sàn tối thiểu tính theo bề rộng ký tự, để tiêu đề lane không bị cắt cụt
khi cửa sổ hẹp.
"""
floor = self.fontMetrics().averageCharWidth() * self._LANE_FLOOR_CH + 24
for col in self.columns.values():
if col.minimumWidth() != floor:
col.setMinimumWidth(floor)
# ---- rendering ----------------------------------------------------------
def _card_text(self, t: dict) -> str:
"""Chuỗi hiển thị trên một thẻ task: tiêu đề kèm mốc thời gian/độ ưu tiên."""
prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "")
ai = "[AI] " if t.get("is_ai_generated") else ""
sched = t.get("schedule", {})
when = sched.get("run_at") if sched.get("enabled") else None
when_line = when or tr("schedtask.no_schedule")
chain = ""
if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"):
chain = " (linked)"
last = t.get("logs", {}).get("last_status")
last_line = {"success": tr("schedtask.last_success"),
"failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never"))
return (f"{ai}{t.get('title', '')}{chain}\n"
f"{when_line} {prio}\n{last_line}")
def refresh(self) -> List[dict]:
"""Re-render every lane from disk. Returns the full task list so the
shell can hand the same read to ``CalendarView.set_tasks`` without a
second ``list_tasks`` call."""
all_tasks = self._repo.list()
counts = {s: 0 for s in STATUSES}
for col in self.columns.values():
col.clear()
for t in all_tasks:
status = t.get("status", "backlog")
if status not in self.columns:
continue
counts[status] += 1
item = QListWidgetItem(self._card_text(t))
item.setData(Qt.UserRole, t["task_id"])
self.columns[status].addItem(item)
pal = current_palette()
for status, col in self.columns.items():
self.column_headers[status].setText(
f"{tr(f'schedtask.status.{status}')} ({counts[status]})")
# Dropping a card into Running STARTS the task for real, so that
# lane is outlined while it holds anything.
if status == "running" and counts[status]:
col.setStyleSheet(
f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;")
self.column_headers[status].setStyleSheet(
f"font-weight:600; color: {pal.warning};")
else:
col.setStyleSheet("")
self.column_headers[status].setStyleSheet("font-weight:600;")
if col.count() == 0:
empty = QListWidgetItem(tr("schedtask.no_tasks"))
empty.setFlags(Qt.NoItemFlags)
col.addItem(empty)
self.counts_changed.emit(counts)
return all_tasks
# ---- actions --------------------------------------------------------
def _on_double_click(self, item: QListWidgetItem) -> None:
"""Bấm đúp một thẻ: mở trình sửa task đó."""
tid = item.data(Qt.UserRole)
if tid:
self.edit_requested.emit(tid)
def _on_task_dropped(self, task_id: str, new_status: str) -> None:
"""Dropping a card ACTS on the task via ``TaskApplicationService.
move_to_status`` — see that method's docstring for the exact rules."""
result = self._service.move_to_status(task_id, new_status)
if result is None:
self.refresh()
return
if result.blocked:
self.refresh() # can't drag a running task
return
if result.ran_now:
self._emit_run_now_message(result.run_now_result,
(result.task or {}).get("title", ""))
self.refresh()
return
self.refresh()
if result.needs_schedule:
# No time set yet — a silently-disabled "Scheduled" card would
# never run and look broken. Open the editor right away.
self.status_message.emit(tr("schedtask.msg_set_schedule"))
self.edit_requested.emit(task_id)
@staticmethod
def _is_multi_selection(item, selected) -> bool:
"""True when the right-clicked card is part of an existing multi-item
selection — pure boolean, kept separate from _context_menu so it's
testable without ever invoking Qt's (modal, event-loop-blocking) menu."""
return len(selected) > 1 and item in selected
def _context_menu(self, col: _KanbanColumn, pos) -> None:
"""Menu chuột phải trên một thẻ: chạy ngay, xem log, tạo task tiếp theo, xoá.
Đang chọn nhiều thẻ thì chuyển sang menu xoá hàng loạt.
"""
item = col.itemAt(pos)
if item is None or not item.data(Qt.UserRole):
return
selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)]
if self._is_multi_selection(item, selected):
self._bulk_delete_menu(col, pos, selected)
return
tid = item.data(Qt.UserRole)
task = self._repo.get(tid)
if not task:
return
menu = QMenu(col)
run_act = menu.addAction(tr("schedtask.menu_run"))
edit_act = menu.addAction(tr("schedtask.menu_edit"))
dup_act = menu.addAction(tr("schedtask.menu_duplicate"))
paused = task.get("status") == "paused"
pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause"))
logs_act = menu.addAction(tr("schedtask.menu_logs"))
hist_act = menu.addAction(tr("schedtask.menu_history"))
next_act = menu.addAction(tr("schedtask.menu_create_next"))
menu.addSeparator()
del_act = menu.addAction(tr("schedtask.menu_delete"))
chosen = menu.exec(col.viewport().mapToGlobal(pos))
if chosen == run_act:
self._emit_run_now_message(self._service.run_now(tid), task.get("title", ""))
self.refresh()
elif chosen == edit_act:
self.edit_requested.emit(tid)
elif chosen == dup_act:
self._service.duplicate(tid)
self.refresh()
elif chosen == pause_act:
self._service.toggle_pause(tid)
self.refresh()
elif chosen == logs_act:
self._view_logs(task)
elif chosen == hist_act:
RunHistoryDialog(task, self).exec()
elif chosen == next_act:
self._create_next_from_output(task)
elif chosen == del_act:
if QMessageBox.question(self, tr("schedtask.menu_delete"),
tr("schedtask.delete_confirm", title=task.get("title", ""))
) == QMessageBox.Yes:
self._service.delete(tid)
self.refresh()
def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None:
"""Menu xoá hàng loạt khi đang chọn nhiều thẻ."""
menu = QMenu(col)
del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected)))
chosen = menu.exec(col.viewport().mapToGlobal(pos))
if chosen == del_act:
self._confirm_and_delete_selected(selected)
def _confirm_and_delete_selected(self, selected) -> bool:
"""Confirm, then delete every task in ``selected``. Split out of
_bulk_delete_menu so tests can drive it directly without having to
fake a real (modal, event-loop-blocking) QMenu popup."""
if QMessageBox.question(
self, tr("schedtask.menu_delete"),
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
return False
ids = [it.data(Qt.UserRole) for it in selected if it.data(Qt.UserRole)]
self._service.bulk_delete(ids)
self.refresh()
return True
def _emit_run_now_message(self, result, title: str = "") -> None:
"""Báo kết quả của lệnh "chạy ngay" ra thanh trạng thái."""
if result is None:
return
if result.ok:
self.status_message.emit(tr("schedtask.msg_running", title=title))
elif result.reason == "manual_task":
self.status_message.emit(tr("schedtask.msg_manual_norun"))
elif result.reason == "no_scheduler":
self.status_message.emit(tr("schedtask.msg_no_scheduler"))
def _view_logs(self, task: dict) -> None:
"""Mở thư mục hiện vật chứa log của các lượt chạy task này."""
from cowork_local.core.tasks import ARTIFACTS_DIR
run_id = task.get("logs", {}).get("last_run_id")
if not run_id:
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
return
folder = ARTIFACTS_DIR / task["task_id"] / run_id
if folder.exists():
open_path(str(folder))
else:
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
def _create_next_from_output(self, task: dict) -> None:
"""Scaffold a follow-up task pre-wired to consume this task's output.
Chain-cycle validation (``chain_error``) is core/tasks.py domain
logic already, not duplicated here — only the save + edit-request
wiring is this widget's job."""
nxt = new_task(tr("schedtask.next_of", title=task.get("title", "")))
nxt["task_type"] = "cowork"
nxt["input"]["mode"] = "previous_task_output"
nxt["input"]["previous_task_id"] = task["task_id"]
nxt["dependency"]["previous_task_id"] = task["task_id"]
err = chain_error(self._repo.list() + [nxt], task["task_id"], nxt["task_id"])
if err:
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
return
self._repo.save(nxt)
task["dependency"]["next_task_id"] = nxt["task_id"]
task["dependency"]["pass_output_to_next"] = True
if task["dependency"].get("run_next_mode", "none") == "none":
task["dependency"]["run_next_mode"] = "run_after_success"
self._repo.save(task)
self.refresh()
self.edit_requested.emit(nxt["task_id"])
__all__ = ["KanbanBoardWidget"]
@@ -0,0 +1,74 @@
"""RunHistoryDialog — one task's run history as a table (R08-T11, split out
of ``kanban_board_widget.py`` to keep that file under the 400-line cap;
originally ``ui/schedule_task_tab.py::_RunHistoryDialog``, lines 496-548)."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QAbstractItemView, QDialog, QDialogButtonBox, QLabel, QTableWidget,
QTableWidgetItem, QVBoxLayout,
)
from cowork_local.i18n import tr
from cowork_local.ui.osutil import open_path
class RunHistoryDialog(QDialog):
"""Run history of one task as a table (newest first): time, status, error;
double-click a row to open that run's artifact folder."""
def __init__(self, task: dict, parent=None):
"""Hộp thoại xem lịch sử các lần chạy của một task."""
super().__init__(parent)
self._task = task
self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}")
self.resize(620, 380)
root = QVBoxLayout(self)
hint = QLabel(tr("schedtask.hist_hint"))
hint.setObjectName("hint")
root.addWidget(hint)
runs = list(reversed(task.get("runs", []) or []))
self.table = QTableWidget(len(runs), 4)
self.table.setHorizontalHeaderLabels([
tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"),
tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"),
])
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
for row, run in enumerate(runs):
cells = (
run.get("finished_at", ""),
str(run.get("status", "")),
run.get("run_id", ""),
(run.get("error") or "")[:200],
)
for col, text in enumerate(cells):
item = QTableWidgetItem(str(text))
if col == 0:
item.setData(Qt.UserRole, run.get("run_id", ""))
self.table.setItem(row, col, item)
self.table.resizeColumnsToContents()
self.table.horizontalHeader().setStretchLastSection(True)
self.table.itemDoubleClicked.connect(self._open_artifact)
root.addWidget(self.table, 1)
buttons = QDialogButtonBox(QDialogButtonBox.Close)
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
root.addWidget(buttons)
def _open_artifact(self, item: QTableWidgetItem) -> None:
"""Bấm một dòng: mở thư mục hiện vật của lượt chạy đó."""
from cowork_local.core.tasks import ARTIFACTS_DIR
first = self.table.item(item.row(), 0)
run_id = first.data(Qt.UserRole) if first else ""
if not run_id:
return
folder = ARTIFACTS_DIR / self._task["task_id"] / run_id
if folder.exists():
open_path(str(folder))
__all__ = ["RunHistoryDialog"]
@@ -0,0 +1,212 @@
"""ScheduleTaskTab shell (R08-T11) — assembles
``kanban_board_widget.py::KanbanBoardWidget`` and
``calendar_view_widget.py::CalendarView`` behind the header/view-switch that
used to be inline in ``ui/schedule_task_tab.py`` (lines 81-245 of the
original 795-line file: header, view-tab wiring, lane-fit event filter moved
into the Kanban widget itself, the belt-and-braces 10s refresh timer).
Task EDITING (opening ``TaskEditorDialog``) lives HERE, not in either child
widget, because both need the exact same "open the editor for this task id"
behaviour — Kanban's double-click/edit-menu and Calendar's task click both
request it via a signal instead of each importing ``TaskEditorDialog``
themselves.
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
from PySide6.QtCore import QTimer, Signal
from PySide6.QtWidgets import (
QHBoxLayout, QLabel, QPushButton, QSizePolicy, QStackedWidget,
QTabBar, QVBoxLayout, QWidget,
)
from cowork_local.core.tasks import STATUSES, list_tasks, load_task, new_task, save_task
from cowork_local.i18n import on_language_changed, tr
from cowork_local.presentation.scheduling.calendar_view_widget import CalendarView
from cowork_local.presentation.scheduling.kanban_board_widget import KanbanBoardWidget
from cowork_local.state import AppContext
from cowork_local.ui.icons import icon
_VIEWS = ("kanban", "calendar")
class ScheduleTaskTab(QWidget):
"""Màn Lịch trình: phần vỏ ghép bảng Kanban và lịch tháng.
Vỏ giữ ba việc mà cả hai khung nhìn đều cần: thanh tiêu đề, nút chuyển
khung nhìn, và mở hộp thoại sửa task. Việc sửa task nằm ở đây (không ở
từng widget con) vì Kanban lẫn Lịch đều cần đúng một hành vi "mở trình
sửa cho task id này" — cả hai phát tín hiệu, vỏ mở hộp thoại.
"""
status_message = Signal(str)
def __init__(self, ctx: AppContext, scheduler=None, tasks_dir: Optional[Path] = None):
"""Vỏ màn Lịch task, ghép bảng Kanban và lịch.
``tasks_dir`` để None thì dùng thư mục mặc định. Cho phép truyền vào là điểm
mới của R08-T11 — bản gộp cũ viết cứng, nên không test được nếu không đụng
thư mục cấu hình thật của người dùng.
"""
super().__init__()
self.ctx = ctx
self.scheduler = scheduler # TaskScheduler (may be None in tests)
# None -> the app's default TASKS_DIR (core/tasks.py). Overridable
# (new in R08-T11; the original monolithic tab hardcoded None with no
# way to point it at a tmp_path) so this shell is actually testable
# without touching the user's real config folder — same shape
# TaskScheduler.__init__ already accepts.
self._tasks_dir: Optional[Path] = tasks_dir
root = QVBoxLayout(self)
# ---- header ----------------------------------------------------
header = QHBoxLayout()
self._title = QLabel()
self._title.setStyleSheet("font-weight:700; font-size:15px;")
self.counts_lbl = QLabel("")
self.counts_lbl.setObjectName("hint")
self.counts_lbl.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
self.counts_lbl.setMinimumWidth(0)
self.add_btn = QPushButton()
self.add_btn.setIcon(icon("plus"))
self.add_btn.setObjectName("primary")
self.add_btn.clicked.connect(self._add_task)
self.ai_btn = QPushButton()
self.ai_btn.setIcon(icon("sparkle"))
self.ai_btn.clicked.connect(self._ai_create)
# Two views of the same tasks, so they read as a pair of tabs rather
# than a drop-list you have to open to discover the Calendar exists.
self.view_tabs = QTabBar()
self.view_tabs.setObjectName("viewTabs")
self.view_tabs.setDrawBase(False)
self.view_tabs.setExpanding(False)
for _v in _VIEWS:
self.view_tabs.addTab("")
self.view_tabs.currentChanged.connect(self._on_view_changed)
header.addWidget(self._title)
header.addWidget(self.counts_lbl, 1)
header.addWidget(self.view_tabs)
header.addWidget(self.add_btn)
header.addWidget(self.ai_btn)
root.addLayout(header)
# ---- board / calendar (two views of the SAME tasks) -----------------
self._view_stack = QStackedWidget()
self.kanban = KanbanBoardWidget(ctx, tasks_dir=self._tasks_dir, scheduler=scheduler)
self.kanban.status_message.connect(self.status_message.emit)
self.kanban.counts_changed.connect(self._on_counts_changed)
self.kanban.edit_requested.connect(self._edit_task)
self._view_stack.addWidget(self.kanban)
self.calendar = CalendarView()
self.calendar.edit_task.connect(self._edit_task)
self.calendar.add_task_on_date.connect(self._add_task_on_date)
self._view_stack.addWidget(self.calendar)
root.addWidget(self._view_stack, 1)
if self.scheduler is not None:
self.scheduler.tasks_changed.connect(self.refresh)
self.scheduler.task_started.connect(lambda _tid: self.refresh())
self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh())
# Belt-and-braces: also re-read the board every 10s so a card's lane
# ALWAYS reflects reality (Scheduled → Running → Done) even if some
# change slipped past the signals (e.g. task files edited externally).
self._refresh_timer = QTimer(self)
self._refresh_timer.setInterval(10_000)
self._refresh_timer.timeout.connect(self.refresh)
self._refresh_timer.start()
self.refresh()
on_language_changed(self._retranslate)
# ---- i18n ------------------------------------------------------------
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, nút và tên hai khung nhìn."""
self._title.setText(tr("schedtask.title"))
self.add_btn.setText(tr("schedtask.add_btn"))
self.add_btn.setToolTip(tr("schedtask.add_tooltip"))
self.ai_btn.setText(tr("schedtask.ai_btn"))
self.ai_btn.setToolTip(tr("schedtask.ai_tooltip"))
for i, v in enumerate(_VIEWS):
self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}"))
self.kanban.retranslate()
self.refresh()
# ---- Kanban / Calendar view switch --------------------------------
def _on_view_changed(self) -> None:
"""Đổi khung nhìn Kanban ⇄ Lịch theo tab đang chọn."""
self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
def _on_counts_changed(self, counts: dict) -> None:
"""Cập nhật dòng tóm tắt số task theo trạng thái.
Chỉ liệt kê trạng thái có task; đặt luôn tooltip để khi thanh bị co hẹp
người dùng vẫn đọc được đầy đủ.
"""
summary = " ".join(
f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])
self.counts_lbl.setText(summary)
self.counts_lbl.setToolTip(summary) # full text stays reachable if clipped
def refresh(self) -> None:
"""Đọc lại toàn bộ task và vẽ lại cả Kanban lẫn Lịch."""
all_tasks = self.kanban.refresh()
self.calendar.set_tasks(all_tasks)
# ---- task creation / editing (shared by Kanban + Calendar) -----------
def _save_and_refresh(self, task: dict) -> None:
"""Ghi task xuống đĩa rồi vẽ lại màn hình."""
save_task(task, self._tasks_dir)
self.refresh()
def _add_task(self) -> None:
"""Mở trình sửa để tạo task mới; chỉ lưu khi người dùng bấm xác nhận."""
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
dlg = TaskEditorDialog(None, list_tasks(self._tasks_dir), self, ctx=self.ctx)
if dlg.exec() and dlg.edited_task:
self._save_and_refresh(dlg.edited_task)
self.status_message.emit(tr("schedtask.msg_created"))
def _edit_task(self, task_id: str) -> None:
"""Mở trình sửa cho một task đã có. Không tìm thấy id thì bỏ qua lặng lẽ
(task có thể vừa bị xoá ở khung nhìn khác).
"""
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
task = load_task(task_id, self._tasks_dir)
if not task:
return
dlg = TaskEditorDialog(task, list_tasks(self._tasks_dir), self, ctx=self.ctx)
if dlg.exec() and dlg.edited_task:
self._save_and_refresh(dlg.edited_task)
def _add_task_on_date(self, date_str: str) -> None:
"""Create a task pre-filled with the clicked calendar date (default
09:00) — same editor Add Task opens, nothing is saved until confirmed."""
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"})
dlg = TaskEditorDialog(t, list_tasks(self._tasks_dir), self, ctx=self.ctx)
if dlg.exec() and dlg.edited_task:
self._save_and_refresh(dlg.edited_task)
self.status_message.emit(tr("schedtask.msg_created"))
# ---- AI create ----------------------------------------------------------
def _ai_create(self) -> None:
"""Mở hộp thoại nhờ AI lập kế hoạch, rồi lưu toàn bộ task nó sinh ra."""
from cowork_local.presentation.scheduling.ai_task_creator_dialog import (
AiTaskCreatorDialog,
)
dlg = AiTaskCreatorDialog(self.ctx, self)
if dlg.exec() and dlg.created_tasks:
for t in dlg.created_tasks:
save_task(t, self._tasks_dir)
self.refresh()
self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks)))
__all__ = ["ScheduleTaskTab"]