Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions analyzer/reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

AGENT_LOADERS = {"claude-code": claude, "codex": codex}
AGENT_NAMES = {"claude-code": "Claude Code", "codex": "Codex"}
PERSONA_DAYS_BY_PERIOD = {"today": 1, "week": 7, "month": 30, "all": 3650}
PERSONA_DAYS_BY_PERIOD = {"today": 1, "week": 7, "last7": 7, "month": 30, "all": 3650}


def _entry_date(entry: UsageEntry) -> date:
Expand All @@ -38,6 +38,8 @@ def _period_bounds(period: str, today: date) -> tuple[date | None, date]:
return today, today
if period == "week":
return today - timedelta(days=today.weekday()), today
if period == "last7":
return today - timedelta(days=6), today
if period == "month":
return today.replace(day=1), today
if period == "all":
Expand Down Expand Up @@ -167,7 +169,7 @@ def _build_comparison(
date_from: date,
date_to: date,
) -> dict[str, Any]:
if period not in {"week", "month"}:
if period not in {"week", "last7", "month"}:
return _empty_comparison(period)

total_days = (date_to - date_from).days + 1
Expand Down Expand Up @@ -204,7 +206,7 @@ def _build_comparison(

def build_report_data(agents: list[AgentInfo], period: str = "month") -> dict[str, Any]:
"""
period: "today" | "week" | "month" | "all"
period: "today" | "week" | "last7" | "month" | "all"
回傳 dict,包含:
period_label: str
date_from: str
Expand All @@ -219,13 +221,13 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> dict[st
today = datetime.now().astimezone().date()
date_from, date_to = _period_bounds(period, today)
hours_back = 0 if date_from is None else ((date_to - date_from).days + 2) * 24
if date_from is not None and period in {"week", "month"}:
if date_from is not None and period in {"week", "last7", "month"}:
total_days_for_comparison = (date_to - date_from).days + 1
prev_date_from = date_from - timedelta(days=total_days_for_comparison)
hours_back = ((date_to - prev_date_from).days + 2) * 24

raw_entries: list[UsageEntry] = []
use_recent_codex = period in {"today", "week", "month"}
use_recent_codex = period in {"today", "week", "last7", "month"}
for agent in agents:
raw_entries.extend(
_load_agent_entries(agent, hours_back, use_recent_codex=use_recent_codex)
Expand Down Expand Up @@ -337,6 +339,7 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> dict[st
})

return {
"period": period,
"period_label": f"{date_from.isoformat()} -> {date_to.isoformat()}",
"date_from": date_from.isoformat(),
"date_to": date_to.isoformat(),
Expand Down
2 changes: 1 addition & 1 deletion menubar.py
Original file line number Diff line number Diff line change
Expand Up @@ -1408,7 +1408,7 @@ def _analysis_period_from_project_range(project_range: str) -> str:
if project_range == "1d":
return "today"
if project_range == "7d":
return "week"
return "last7"
if project_range == "30d":
return "last30"
if project_range == "all":
Expand Down
86 changes: 84 additions & 2 deletions tests/test_analyzer_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ def fake_generate_analysis_report(

def test_analysis_period_from_project_range() -> None:
assert menubar._analysis_period_from_project_range("1d") == "today"
assert menubar._analysis_period_from_project_range("7d") == "week"
assert menubar._analysis_period_from_project_range("7d") == "last7"
assert menubar._analysis_period_from_project_range("30d") == "last30"
assert menubar._analysis_period_from_project_range("all") == "all"

Expand Down Expand Up @@ -336,6 +336,88 @@ def fake_load_agent_entries(
}


def test_report_last7_includes_previous_period_comparison(monkeypatch: Any) -> None:
class FixedDateTime:
@staticmethod
def now() -> datetime:
return datetime(2026, 5, 21, 12, tzinfo=UTC)

agent = AgentInfo("codex", "Codex", "~/.codex", True)
entries = [
UsageEntry(
timestamp=datetime(2026, 5, 8, tzinfo=UTC),
session_id="prev-1",
message_id="prev-1",
request_id="",
model="gpt-5-mini",
input_tokens=100,
output_tokens=0,
cache_creation_tokens=0,
cache_read_tokens=0,
cost_usd=1.0,
project="old",
agent_id="codex",
),
UsageEntry(
timestamp=datetime(2026, 5, 14, tzinfo=UTC),
session_id="prev-2",
message_id="prev-2",
request_id="",
model="gpt-5-codex",
input_tokens=300,
output_tokens=0,
cache_creation_tokens=0,
cache_read_tokens=0,
cost_usd=3.0,
project="usage",
agent_id="codex",
),
UsageEntry(
timestamp=datetime(2026, 5, 15, tzinfo=UTC),
session_id="cur-1",
message_id="cur-1",
request_id="",
model="gpt-5-codex",
input_tokens=600,
output_tokens=0,
cache_creation_tokens=0,
cache_read_tokens=0,
cost_usd=6.0,
project="usage",
agent_id="codex",
),
]
calls: dict[str, int] = {}

def fake_load_agent_entries(
received_agent: AgentInfo,
hours_back: int = 0,
*,
use_recent_codex: bool = False,
) -> list[UsageEntry]:
assert received_agent == agent
assert use_recent_codex is True
calls["hours_back"] = hours_back
return entries

monkeypatch.setattr(reporter, "datetime", FixedDateTime)
monkeypatch.setattr(reporter, "_load_agent_entries", fake_load_agent_entries)
monkeypatch.setattr("analyzer.reporter.subscription.load_subscriptions", lambda: [])

data = reporter.build_report_data([agent], "last7")

assert calls == {"hours_back": 360}
assert data["summary"]["total_tokens"] == 600
assert data["comparison"] == {
"period": "last7",
"has_prev": True,
"prev_tokens": 400,
"prev_cost": 4.0,
"prev_projects": ["old", "usage"],
"prev_model_share": {"gpt-5-codex": 75.0, "gpt-5-mini": 25.0},
}


def test_build_report_data_includes_serialized_persona(monkeypatch: Any) -> None:
histogram = [0] * 24
histogram[9] = 3
Expand All @@ -354,7 +436,7 @@ def fake_load_profile(days_back: int = 30) -> persona_loader.PersonaProfile:
monkeypatch.setattr("analyzer.reporter.persona_loader.load_profile", fake_load_profile)
monkeypatch.setattr("analyzer.reporter.subscription.load_subscriptions", lambda: [])

data = reporter.build_report_data([], "week")
data = reporter.build_report_data([], "last7")

assert calls == [7]
assert data["persona"] == {
Expand Down
2 changes: 2 additions & 0 deletions tests/test_usage_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ def test_main_codex_warning_checks_only_codex_setup(monkeypatch: pytest.MonkeyPa
[
(["usage", "report"], "last30"),
(["usage", "report", "--last30"], "last30"),
(["usage", "report", "--last7"], "last7"),
(["usage", "report", "--all"], "all"),
],
)
Expand Down Expand Up @@ -254,6 +255,7 @@ def test_parse_report_args_defaults_to_last30() -> None:
("flag", "expected_period"),
[
("--today", "today"),
("--last7", "last7"),
("--week", "week"),
("--month", "month"),
("--all", "all"),
Expand Down
5 changes: 4 additions & 1 deletion usage_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,15 @@ def _load_codex_rate_limits() -> RateLimits | None:
"output": ("output_tokens", True),
}

REPORT_HELP = """Usage: usage report [--last30|--all|--today|--week|--month] [--out PATH]
REPORT_HELP = """Usage: usage report [--last30|--all|--today|--last7|--week|--month] [--out PATH]

Generate an HTML usage report.

Options:
--last30 Include the last 30 days (default)
--all Include all usage data
--today Include today only
--last7 Include the last 7 days
--week Include this week
--month Include this month
--out PATH Save to a specific path
Expand Down Expand Up @@ -110,6 +111,8 @@ def _parse_report_args(args: list[str]) -> tuple[str, str | None, bool]:
period = "last30"
elif arg == "--today":
period = "today"
elif arg == "--last7":
period = "last7"
elif arg == "--week":
period = "week"
elif arg == "--month":
Expand Down