refactor(dashboard): R08-T13 — dashboard_tab.py 438 -> 215, tách 3 widget

token_usage_card_widget.py    93   5 thẻ số liệu + thẻ Ngân sách
    usage_chart_widget.py        119   biểu đồ tuần/tháng/năm + đường so sánh
    habits_widget.py             171   thói quen dùng token + nhận xét của AI

Ba widget THẬT, không phải mixin — khác với shell và Co4E, ba mảng này tách
bạch trên màn hình và không đọc state của nhau. Giao tiếp bằng signal:
budget_applied, filter_changed, status_message.

Điểm cần biết: các nút lật khoảng và hai ô chọn thuộc về UsageChartWidget
nhưng được Dashboard nhấc lên hàng điều khiển ở trên. Chúng là control của
biểu đồ, chỉ hiển thị ở chỗ khác.

Giữ 18 cầu tương thích cho tên cũ vì check_dashboard, check_design_parity và
check_controls_alive đọc thẳng self.card_total, self._chart_period_lbl...

756 test xanh. check_dashboard, check_design_parity, check_controls_alive qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-27 22:05:48 +09:00
co-authored by Claude Opus 5
parent f8e22f5f5b
commit 062ea4ba21
4 changed files with 509 additions and 345 deletions
@@ -0,0 +1,119 @@
"""Biểu đồ mức dùng theo khoảng thời gian — R08-T13.
Cắt khoảng đang chọn thành từng phần: TUẦN → 7 ngày (T2–CN) · THÁNG → các
tuần W1…Wn · NĂM → 12 tháng.
Đường nét đứt là mốc so sánh với khoảng liền trước cùng loại — "tuần trước" ở
chế độ tuần, "tháng trước" ở chế độ tháng. Nó vẽ ở mức trung bình mỗi điểm của
khoảng trước để nằm đúng thang đo, còn nhãn thì hiện % thay đổi của tổng.
Các nút lật khoảng và hai ô chọn nằm ở đây nhưng được đặt lên hàng điều khiển
của Dashboard — chúng thuộc về biểu đồ, chỉ hiển thị ở chỗ khác.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget,
)
from ...core import usage_tracker as ut
from ...i18n import tr
from ...theme import current_palette
from ...ui.icons import icon
from ...ui.spline_chart import SplineChart
from ...ui.widgets import fmt_tokens as _fmt_tokens
_REF_KEY = {"week": "dashboard.ref_last_week",
"month": "dashboard.ref_last_month",
"year": "dashboard.ref_last_year"}
class UsageChartWidget(QWidget):
#: Người dùng đổi khoảng/độ mịn/chỉ số — Dashboard nạp lại cả màn, không
#: riêng biểu đồ, vì thẻ số liệu cũng đi theo bộ lọc.
filter_changed = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.offset = 0 # 0 = khoảng hiện tại; số âm = lùi về trước
# --- các control, sẽ được Dashboard nhấc lên hàng điều khiển ---
self.prev_btn = QPushButton()
self.prev_btn.setIcon(icon("chevron-left"))
self.prev_btn.setFixedWidth(30)
self.prev_btn.clicked.connect(self._lui)
self.period_lbl = QLabel()
self.period_lbl.setObjectName("hint")
self.period_lbl.setAlignment(Qt.AlignCenter)
self.period_lbl.setMinimumWidth(170)
self.next_btn = QPushButton()
self.next_btn.setIcon(icon("chevron-right"))
self.next_btn.setFixedWidth(30)
self.next_btn.clicked.connect(self._toi)
self.gran_combo = QComboBox()
self.metric_combo = QComboBox()
self.metric_combo.currentIndexChanged.connect(self.filter_changed)
# --- thân widget: tiêu đề + biểu đồ ---
v = QVBoxLayout(self)
v.setContentsMargins(0, 0, 0, 0)
head = QHBoxLayout()
self.title = QLabel()
self.title.setStyleSheet("font-weight:600;")
head.addWidget(self.title, 1)
v.addLayout(head)
self.chart = SplineChart()
v.addWidget(self.chart)
# ---- lật khoảng ------------------------------------------------------
def _lui(self) -> None:
self.offset -= 1
self.filter_changed.emit()
def _toi(self) -> None:
# Không cho đi quá khoảng hiện tại: tương lai thì chưa có dữ liệu.
if self.offset < 0:
self.offset += 1
self.filter_changed.emit()
# ---- vẽ --------------------------------------------------------------
def refresh(self, events, pricing: dict) -> None:
gran = self.gran_combo.currentData() or "week"
metric = self.metric_combo.currentData() or "cost"
parts = ut.period_breakdown(events, gran, pricing, offset=self.offset)
mi = 0 if metric == "tokens" else 1 # (nhãn, token, tiền)
pts = [(row[0], float(row[mi + 1])) for row in parts]
# Dạng tiền rút gọn: hộp nhãn trục y hẹp, format_cost đầy đủ (tới 4 số
# lẻ với USD) tràn ra ngoài và che mất con số.
fmt = (_fmt_tokens if metric == "tokens"
else (lambda v: ut.format_cost_compact(v, pricing)))
cur = ut.period_totals(events, gran, pricing, self.offset)
prev = ut.period_totals(events, gran, pricing, self.offset - 1)
refs = []
if prev[mi] > 0:
# Màu mờ có chủ ý: đây là mốc tham chiếu, không được tranh chấp
# với đường dữ liệu màu nhấn.
refs.append((prev[mi] / max(1, len(parts)),
"%s %s" % (tr(_REF_KEY.get(gran, _REF_KEY["week"])),
_delta(cur[mi], prev[mi])),
current_palette().text_muted))
self.chart.set_reference_lines(refs)
self.chart.set_data(pts, fmt, tr("dashboard.metric_%s" % metric))
self.period_lbl.setText(ut.period_range_label(gran, self.offset))
self.next_btn.setEnabled(self.offset < 0)
def _delta(cur: float, prev: float) -> str:
if prev <= 0:
return ""
pct = (cur - prev) / prev * 100.0
return "(%+.0f%%)" % pct