feat(R08): split ScheduleTaskTab, FolderTab, DashboardTab, StructureGraphView

Team Hoa, EPIC R08 (UI/Application Separation) - Team Hoa scope only
(R08-T11 -> T14; R08-T01->T10 belong to Team Duy/Team Nam).

- R08-T11: ui/schedule_task_tab.py (795 lines) -> presentation/scheduling/
  {kanban_board_widget,calendar_view_widget,ai_task_creator_dialog,
  ai_task_import_dialog,run_history_dialog}.py + schedule_task_tab.py
  shell. Kanban CRUD/drag-drop now goes through
  application/scheduling/task_application_service.py (R07-T04) instead of
  ~30 lines of inline if/elif per drag target.
- R08-T12: ui/folder_tab.py (1587 lines, the largest of the four) ->
  presentation/folder/{workspace_file_tree,document_preview_manager,
  code_editor,office_document_renderer,ai_file_editor_dialog,
  ai_edit_model_resolver,ai_edit_pipeline}.py + folder_tab.py shell.
  Closes the R06-T05 loop: FileWorkspaceService existed since R06 with
  zero production call sites (confirmed by grep); every plain-text write
  (save/create/write_content) now goes through it, gaining path
  containment and a Python-syntax warning the original code never had.
  Pure helpers (_read_text, _is_probably_text, _pptx_available,
  _split_code_block, _parse_ai_output) moved to
  application/workspaces/{file_preview_helpers,ai_edit_output}.py.
- R08-T13: ui/dashboard_tab.py (437 lines) -> presentation/dashboard/
  {token_usage_card_widget,usage_chart_widget,habits_widget}.py +
  dashboard_tab.py shell, backed by a new
  application/monitoring/dashboard_query_service.py (pricing/period/
  summary queries the three widgets used to each recompute separately).
  Directory-ownership note left in the checklist for Team Nam.
- R08-T14: ui/structure_graph_view.py (1035 lines) ->
  presentation/graph/{graph_scene_items,graph_renderer,
  graph_messages_view,graph_qa_widget}.py + structure_graph_view.py
  shell. Extraction helpers (_pdf_to_markdown, _extract_file_contents)
  moved to application/workspaces/graph_index_service.py (pure Python).
  Renderer and Q&A panel talk only through signals
  (node_selected/graph_rendered/raw_json_ready/project_changed) - neither
  imports the other.
- presentation/shared/web_engine_support.py: HAS_WEB_ENGINE, previously
  duplicated (folder_tab imported it FROM structure_graph_view.py) - now
  one shared flag instead of one screen importing another screen's module.

All four old ui/*.py files deleted; app.py and ui/workspace_tab.py updated
to the new import paths (each god-file only had 1-2 real construction
sites, so import sites were updated directly rather than kept as a
strangler-fig shim - unlike core/tools.py at R05, which had dozens).

pytest: 377 pass (+94 vs the R07 baseline of 328; same 4 pre-existing
failures as the R05/R06 baseline, unrelated to this work).
scripts/check_imports.py: PASS. python -c "import cowork_local.app": OK.
Every new file < 400 lines (largest: graph_renderer.py, 391).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 20:55:32 +09:00
co-authored by Claude Sonnet 5
parent 69ab8e125b
commit 0e51356a7d
51 changed files with 5746 additions and 3877 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Dashboard screen, split into single-responsibility widgets (R08-T13):
``token_usage_card_widget``, ``usage_chart_widget``, ``habits_widget``,
assembled by the ``dashboard_tab`` shell."""
+91
View File
@@ -0,0 +1,91 @@
"""DashboardTab shell (R08-T13) — assembles
``token_usage_card_widget.py::TokenUsageCardWidget``,
``usage_chart_widget.py::UsageChartWidget`` and
``habits_widget.py::HabitsWidget`` behind the scroll area / header / 30s
auto-refresh timer that used to be inline in
``ui/dashboard_tab.py::DashboardTab.__init__`` (lines 40-193 of the original
437-line file).
The one ``DashboardQueryService`` (R08-T13) instance is built here and
shared by all three children so pricing/currency stay consistent across the
whole screen.
"""
from __future__ import annotations
from PySide6.QtCore import QTimer, Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout, QWidget
from cowork_local.application.monitoring import DashboardQueryService
from cowork_local.i18n import on_language_changed, tr
from cowork_local.presentation.dashboard.habits_widget import HabitsWidget
from cowork_local.presentation.dashboard.token_usage_card_widget import TokenUsageCardWidget
from cowork_local.presentation.dashboard.usage_chart_widget import UsageChartWidget
from cowork_local.state import AppContext
from cowork_local.ui.icons import icon
class DashboardTab(QWidget):
status_message = Signal(str)
def __init__(self, ctx: AppContext):
super().__init__()
self.ctx = ctx
self._query = DashboardQueryService(ctx)
outer = QVBoxLayout(self)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
content = QWidget()
scroll.setWidget(content)
outer.addWidget(scroll)
root = QVBoxLayout(content)
head = QHBoxLayout()
self._title = QLabel()
self._title.setStyleSheet("font-weight:700; font-size:15px;")
self.refresh_btn = QPushButton("")
self.refresh_btn.setIcon(icon("refresh"))
self.refresh_btn.setFixedWidth(34)
self.refresh_btn.clicked.connect(self.refresh)
head.addWidget(self._title, 1)
head.addWidget(self.refresh_btn)
root.addLayout(head)
self.token_cards = TokenUsageCardWidget(ctx, self._query)
root.addWidget(self.token_cards)
self.chart = UsageChartWidget(ctx, self._query)
self.chart.period_changed.connect(self.refresh)
self.chart.currency_changed.connect(self.refresh)
root.addWidget(self.chart)
self.habits = HabitsWidget(ctx, self._query)
self.habits.status_message.connect(self.status_message.emit)
root.addWidget(self.habits, 1)
# Auto-refresh every 30s so numbers follow ongoing work.
self._timer = QTimer(self)
self._timer.setInterval(30_000)
self._timer.timeout.connect(self.refresh)
self._timer.start()
on_language_changed(self._retranslate)
self.refresh()
def _retranslate(self) -> None:
self._title.setText(tr("dashboard.title"))
self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
self.token_cards.retranslate()
self.chart.retranslate()
self.habits.retranslate()
self.refresh()
def refresh(self, *_a) -> None:
start, end = self.chart.period_range()
self.token_cards.refresh(start, end)
self.chart.refresh()
self.habits.refresh(start, end)
__all__ = ["DashboardTab"]
+171
View File
@@ -0,0 +1,171 @@
"""HabitsWidget — the usage-habits summary + AI recommendations panel of the
Dashboard (R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``,
lines 154-184/348-372/376-438 of the original 437-line file: the habits/AI
layout, ``refresh()``'s habits-HTML section, ``_apply_saving_strategy``,
``_ai_analyze``).
"""
from __future__ import annotations
from datetime import date
from typing import List, Optional
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextBrowser, QVBoxLayout, QWidget
from PySide6.QtCore import Signal
from cowork_local.application.monitoring import DashboardQueryService
from cowork_local.core.worker import AgentWorker
from cowork_local.i18n import tr
from cowork_local.ui.icons import icon
from cowork_local.ui.widgets import fmt_tokens
class HabitsWidget(QWidget):
status_message = Signal(str)
def __init__(self, ctx, query: DashboardQueryService, parent=None):
super().__init__(parent)
self.ctx = ctx
self._query = query
self._ai_worker: Optional[AgentWorker] = None
self._period_range = (None, None) # set on each refresh(); _ai_analyze reuses it
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
self._habits_title = QLabel()
self._habits_title.setStyleSheet("font-weight:600;")
habits_head = QHBoxLayout()
self.ai_analyze_btn = QPushButton()
self.ai_analyze_btn.setIcon(icon("sparkle"))
self.ai_analyze_btn.clicked.connect(self._ai_analyze)
# Apply an AI-suggested cost-saving strategy — only after the user
# clicks to approve it.
self.apply_strategy_btn = QPushButton()
self.apply_strategy_btn.setIcon(icon("bolt"))
self.apply_strategy_btn.setVisible(False)
self.apply_strategy_btn.clicked.connect(self._apply_saving_strategy)
habits_head.addWidget(self._habits_title, 1)
habits_head.addWidget(self.apply_strategy_btn)
habits_head.addWidget(self.ai_analyze_btn)
root.addLayout(habits_head)
self.habits = QTextBrowser()
self.habits.setOpenExternalLinks(False)
self.habits.setMinimumHeight(160)
root.addWidget(self.habits, 1)
self._ai_title = QLabel()
self._ai_title.setStyleSheet("font-weight:600;")
self._ai_title.setVisible(False)
root.addWidget(self._ai_title)
self.ai_advice = QTextBrowser()
self.ai_advice.setOpenExternalLinks(False)
self.ai_advice.setMinimumHeight(140)
self.ai_advice.setVisible(False)
root.addWidget(self.ai_advice, 1)
self.retranslate()
def retranslate(self) -> None:
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
self.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
self.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
self.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip"))
self._habits_title.setText(tr("dashboard.habits_title"))
def refresh(self, start: date, end: date) -> None:
self._period_range = (start, end)
summary = self._query.summary(start, end)
s, events = summary["stats"], summary["events"]
lines: List[str] = []
if not events:
lines.append(f"<i>{tr('dashboard.no_data')}</i>")
else:
lines.append(f"<b>{tr('dashboard.h_top')}</b>")
lines.append("<ol>")
for label, tok in s["top_labels"]:
pct = int(tok * 100 / s["total"]) if s["total"] else 0
lines.append(f"<li>{label[:60]} — {fmt_tokens(tok)} tokens ({pct}%)</li>")
lines.append("</ol>")
src_parts = ", ".join(
f"{tr(f'app.tab.{k}') if k in ('cowork', 'code') else k}: {fmt_tokens(v)}"
for k, v in s["by_source"])
lines.append(f"<b>{tr('dashboard.h_by_source')}</b>: {src_parts}<br>")
lines.append(f"<b>{tr('dashboard.h_avg')}</b>: "
f"{fmt_tokens(s['avg_per_turn'])} tokens<br>")
if s["busiest_day"]:
lines.append(f"<b>{tr('dashboard.h_busiest_day')}</b>: {s['busiest_day']}<br>")
if s["busiest_hour"] is not None:
lines.append(f"<b>{tr('dashboard.h_busiest_hour')}</b>: "
f"{s['busiest_hour']:02d}:00–{s['busiest_hour']:02d}:59<br>")
if s["estimated_share"] > 0:
lines.append(f"<i>{tr('dashboard.estimated_note', pct=int(s['estimated_share'] * 100))}</i>")
self.habits.setHtml("".join(lines))
def _apply_saving_strategy(self) -> None:
"""Apply an AI-suggested cost-saving strategy AFTER the user
approves: turn on auto-compress and compress earlier (lower
threshold) + compress content before sending it to the agent."""
from PySide6.QtWidgets import QMessageBox
if QMessageBox.question(self, tr("dashboard.strategy_title"),
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
return
cx = self.ctx.config.data.setdefault("context", {})
cx["auto_compact"] = True
cx["compact_threshold"] = 0.6 # compress at 60% of the window (was ~80%)
cx["compress_before_send"] = True # digest context before each turn
self.ctx.save()
self.status_message.emit(tr("dashboard.strategy_applied"))
def _ai_analyze(self) -> None:
"""✨ Send the aggregated numbers (never raw prompt text) to the
active provider and show habit feedback + token-saving
recommendations."""
if self._ai_worker is not None:
return
start, end = self._period_range
if start is None:
return
summary = self._query.summary(start, end)
if not summary["events"]:
self.status_message.emit(tr("dashboard.no_data"))
return
stats = summary["stats"]
self.ai_analyze_btn.setEnabled(False)
self.ai_analyze_btn.setText(tr("dashboard.ai_analyzing"))
ctx = self.ctx
def job(worker: AgentWorker):
from cowork_local.core import usage_tracker as ut
from cowork_local.i18n import get_language
prompt = ut.build_ai_analysis_prompt(stats, get_language())
provider = ctx.build_active_provider()
reply = provider.chat([{"role": "user", "content": prompt}],
cancel=worker.stop_event)
return {"text": (reply.get("content") or "").strip()}
def done(result: dict) -> None:
self._ai_worker = None
self.ai_analyze_btn.setEnabled(True)
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
text = result.get("text") or ""
if text:
self._ai_title.setText(tr("dashboard.ai_advice_title"))
self._ai_title.setVisible(True)
self.ai_advice.setMarkdown(text)
self.ai_advice.setVisible(True)
self.apply_strategy_btn.setVisible(True)
def failed(err: str) -> None:
self._ai_worker = None
self.ai_analyze_btn.setEnabled(True)
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
self.status_message.emit(str(err))
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._ai_worker = w
w.start()
__all__ = ["HabitsWidget"]
@@ -0,0 +1,108 @@
"""TokenUsageCardWidget — the stat-card grid + budget card of the Dashboard
(R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, lines
116-141/226-254/337-346 of the original 437-line file: the card grid layout,
``_apply_budget``, ``_refresh_budget``, and ``refresh()``'s card-filling
section).
"""
from __future__ import annotations
from datetime import date
from PySide6.QtWidgets import QGridLayout, QWidget
from cowork_local.application.monitoring import DashboardQueryService
from cowork_local.i18n import tr
from cowork_local.ui.icons import icon
from cowork_local.ui.widgets import BudgetCard, StatCard, fmt_tokens
class TokenUsageCardWidget(QWidget):
"""Cost is the headline this screen exists for, so it gets a card twice
the height of the rest instead of being the fifth of five identical
tiles — with six equal cards nothing said which number mattered."""
def __init__(self, ctx, query: DashboardQueryService, parent=None):
super().__init__(parent)
self.ctx = ctx
self._query = query
cards_grid = QGridLayout(self)
cards_grid.setSpacing(8)
self.card_total = StatCard()
self.card_in = StatCard()
self.card_out = StatCard()
self.card_cache = StatCard()
self.card_cost = StatCard().as_hero()
# Hero on the left, spanning both rows; the four supporting figures
# fill a 2x2 block beside it.
cards_grid.addWidget(self.card_cost, 0, 0, 2, 1)
for i, card in enumerate((self.card_total, self.card_in,
self.card_out, self.card_cache)):
cards_grid.addWidget(card, i // 2, 1 + i % 2)
# Budget: remaining/budget, direct entry, auto-warns red past 85% used.
self.budget_card = BudgetCard()
self.budget_card.apply_btn.setIcon(icon("check"))
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
cards_grid.addWidget(self.budget_card, 0, 3, 2, 1)
for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
cards_grid.setColumnStretch(col, stretch)
self.retranslate()
def retranslate(self) -> None:
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
def refresh(self, start: date, end: date) -> None:
summary = self._query.summary(start, end)
s, pricing, costs = summary["stats"], summary["pricing"], summary["costs"]
from cowork_local.core import usage_tracker as ut
est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100))
if s["estimated_share"] > 0 else "")
self.card_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]),
tr("dashboard.card_turns", n=s["turns"]))
self.card_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]),
ut.format_cost(costs["in"], pricing))
self.card_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]),
ut.format_cost(costs["out"], pricing))
self.card_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]),
ut.format_cost(costs["cache"], pricing))
self.card_cost.set(tr("dashboard.card_cost"),
ut.format_cost(summary["total_cost"], pricing, digits=2), est_note)
self._refresh_budget()
def _apply_budget(self) -> None:
"""Persist the spin box's value as the new budget — starts a fresh
remaining-balance window (spend before now is no longer counted)."""
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
self._query.set_budget(self.budget_card.budget_spin.value(), ccy)
self.ctx.save()
self._refresh_budget()
def _refresh_budget(self) -> None:
from cowork_local.core import model_pricing as mp
from cowork_local.core import usage_tracker as ut
pricing = self._query.pricing()
status = self._query.budget_status()
if status is None:
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
self.budget_card.budget_spin.setValue(0.0)
return
remaining_disp = mp.convert(status["remaining_usd"], "USD",
pricing.get("currency", "USD"), self.ctx.config)
amount_disp = mp.convert(status["amount_usd"], "USD",
pricing.get("currency", "USD"), self.ctx.config)
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
pct = int(round(status["pct_used"] * 100))
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
# keep the entry field showing the CURRENT budget (in display currency)
# — only when it doesn't already have unsaved focus/edits from the user.
if not self.budget_card.budget_spin.hasFocus():
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
__all__ = ["TokenUsageCardWidget"]
@@ -0,0 +1,179 @@
"""UsageChartWidget — the period pager + granularity/metric/currency
controls + spline chart of the Dashboard (R08-T13, extracted from
``ui/dashboard_tab.py::DashboardTab``, lines 53-114/143-152/201-207/
263-323 of the original 437-line file).
Owns the period SELECTOR (granularity + prev/next offset) that the whole
screen follows — ``token_usage_card_widget.py`` and ``habits_widget.py``
read :meth:`period_range`/:meth:`granularity` rather than keeping their own
copy, and the shell re-refreshes them on :attr:`period_changed`.
"""
from __future__ import annotations
from typing import Tuple
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from cowork_local.application.monitoring import DashboardQueryService
from cowork_local.i18n import tr
from cowork_local.theme import current_palette
from cowork_local.ui.icons import icon
from cowork_local.ui.spline_chart import SplineChart
from cowork_local.ui.widgets import fmt_tokens
class UsageChartWidget(QWidget):
period_changed = Signal() # granularity or offset changed — re-run every widget
currency_changed = Signal() # display currency changed — same, cost text depends on it
def __init__(self, ctx, query: DashboardQueryService, parent=None):
super().__init__(parent)
self.ctx = ctx
self._query = query
self._chart_offset = 0 # 0 = current period; <0 = a past period
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
controls = QHBoxLayout()
controls.setSpacing(6)
self.chart_prev_btn = QPushButton()
self.chart_prev_btn.setIcon(icon("chevron-left"))
self.chart_prev_btn.setFixedWidth(30)
self.chart_prev_btn.clicked.connect(self._chart_prev)
controls.addWidget(self.chart_prev_btn)
self._chart_period_lbl = QLabel()
self._chart_period_lbl.setObjectName("hint")
self._chart_period_lbl.setAlignment(Qt.AlignCenter)
self._chart_period_lbl.setMinimumWidth(170)
controls.addWidget(self._chart_period_lbl)
self.chart_next_btn = QPushButton()
self.chart_next_btn.setIcon(icon("chevron-right"))
self.chart_next_btn.setFixedWidth(30)
self.chart_next_btn.clicked.connect(self._chart_next)
controls.addWidget(self.chart_next_btn)
controls.addSpacing(12)
self.gran_combo = QComboBox()
for g in ("week", "month", "year"):
self.gran_combo.addItem(tr(f"dashboard.gran_{g}"), g)
self.gran_combo.currentIndexChanged.connect(self._on_gran_changed)
controls.addWidget(self.gran_combo)
self.metric_combo = QComboBox()
for m in ("cost", "tokens"):
self.metric_combo.addItem(tr(f"dashboard.metric_{m}"), m)
self.metric_combo.currentIndexChanged.connect(self.refresh)
controls.addWidget(self.metric_combo)
controls.addStretch(1)
# Display-currency picker — both Dashboard and Monitoring read/write
# the same usage.currency config key, so changing it here updates
# cost text everywhere.
self.currency_lbl = QLabel()
self.currency_lbl.setObjectName("hint")
controls.addWidget(self.currency_lbl)
self.currency_combo = QComboBox()
from cowork_local.core import usage_tracker as ut
for cur in ut.SUPPORTED_CURRENCIES:
self.currency_combo.addItem(cur, cur)
idx = self.currency_combo.findData(
(self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
self.currency_combo.setCurrentIndex(max(0, idx))
self.currency_combo.currentIndexChanged.connect(self._on_currency_changed)
controls.addWidget(self.currency_combo)
root.addLayout(controls)
chart_head = QHBoxLayout()
self._chart_title = QLabel()
self._chart_title.setStyleSheet("font-weight:600;")
chart_head.addWidget(self._chart_title, 1)
root.addLayout(chart_head)
self.chart = SplineChart()
root.addWidget(self.chart)
self.retranslate()
def retranslate(self) -> None:
self.currency_lbl.setText(tr("monitoring.overview_currency"))
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
self._chart_title.setText(tr("dashboard.chart_title"))
self.chart_prev_btn.setToolTip(tr("dashboard.chart_prev"))
self.chart_next_btn.setToolTip(tr("dashboard.chart_next"))
# ---- public: the period selector every other widget follows ------------- #
def granularity(self) -> str:
return self.gran_combo.currentData() or "week"
@property
def chart_offset(self) -> int:
return self._chart_offset
def period_range(self) -> Tuple:
return self._query.period_range(self.granularity(), self._chart_offset)
# ---- navigation ------------------------------------------------------------ #
def _on_gran_changed(self, *_a) -> None:
self._chart_offset = 0 # period size changed → back to current
self.period_changed.emit()
def _chart_prev(self) -> None:
self._chart_offset -= 1
self.period_changed.emit()
def _chart_next(self) -> None:
self._chart_offset = min(0, self._chart_offset + 1) # never past the present
self.period_changed.emit()
def _on_currency_changed(self, _idx: int) -> None:
cur = self.currency_combo.currentData()
if not cur:
return
self.ctx.config.data.setdefault("usage", {})["currency"] = cur
self.ctx.save()
self.currency_changed.emit()
@staticmethod
def _delta_txt(cur: float, prev: float) -> str:
"""▲/▼ percent change of ``cur`` vs ``prev`` (empty if no baseline)."""
if not prev:
return ""
pct = (cur - prev) / prev * 100
arrow = "▲" if pct > 0.5 else ("▼" if pct < -0.5 else "•")
return f"{arrow}{abs(pct):.0f}%"
# ---- rendering --------------------------------------------------------------- #
def refresh(self, *_a) -> None:
"""Break the SELECTED period into its parts: WEEK -> 7 days (Mon-Sun)
- MONTH -> weeks W1..Wn - YEAR -> 12 months. A dashed line marks the
previous same-granularity period's average per point with the %
change of the totals."""
from cowork_local.core import usage_tracker as ut
gran = self.granularity()
metric = self.metric_combo.currentData() or "cost"
pts = self._query.chart_series(gran, self._chart_offset, metric)
pricing = self._query.pricing()
mi = 0 if metric == "tokens" else 1
# Compact cost format (2 decimals, K/M above 1,000/1,000,000) — the
# chart's y-axis label box is narrow; format_cost's full precision
# overflowed it, clipping/obscuring the amount.
fmt = fmt_tokens if metric == "tokens" else (lambda v: ut.format_cost_compact(v, pricing))
cur = self._query.period_totals(gran, self._chart_offset)
prev = self._query.period_totals(gran, self._chart_offset - 1)
ref_key = {"week": "dashboard.ref_last_week",
"month": "dashboard.ref_last_month",
"year": "dashboard.ref_last_year"}.get(gran, "dashboard.ref_last_week")
n_points = max(1, len(pts))
refs = []
if prev[mi] > 0:
# Muted on purpose: the comparison line is a reference, not the
# series — it must not compete with the accent-coloured spline.
refs.append((prev[mi] / n_points,
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
current_palette().text_muted))
self.chart.set_reference_lines(refs)
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
self._chart_period_lbl.setText(self._query.period_range_label(gran, self._chart_offset))
self.chart_next_btn.setEnabled(self._chart_offset < 0)
__all__ = ["UsageChartWidget"]