codex-stats 1.3.0__tar.gz → 1.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {codex_stats-1.3.0/src/codex_stats.egg-info → codex_stats-1.4.2}/PKG-INFO +14 -2
  2. {codex_stats-1.3.0 → codex_stats-1.4.2}/README.md +13 -1
  3. {codex_stats-1.3.0 → codex_stats-1.4.2}/pyproject.toml +1 -1
  4. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/__init__.py +1 -1
  5. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/cli.py +83 -10
  6. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/display.py +304 -2
  7. {codex_stats-1.3.0 → codex_stats-1.4.2/src/codex_stats.egg-info}/PKG-INFO +14 -2
  8. {codex_stats-1.3.0 → codex_stats-1.4.2}/tests/test_cli.py +38 -4
  9. {codex_stats-1.3.0 → codex_stats-1.4.2}/tests/test_metrics.py +18 -2
  10. {codex_stats-1.3.0 → codex_stats-1.4.2}/LICENSE +0 -0
  11. {codex_stats-1.3.0 → codex_stats-1.4.2}/setup.cfg +0 -0
  12. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/__main__.py +0 -0
  13. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/completions.py +0 -0
  14. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/config.py +0 -0
  15. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/ingest.py +0 -0
  16. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/metrics.py +0 -0
  17. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/models.py +0 -0
  18. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/otel.py +0 -0
  19. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/transfer.py +0 -0
  20. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/watch_state.py +0 -0
  21. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats.egg-info/SOURCES.txt +0 -0
  22. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats.egg-info/dependency_links.txt +0 -0
  23. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats.egg-info/entry_points.txt +0 -0
  24. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats.egg-info/requires.txt +0 -0
  25. {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats.egg-info/top_level.txt +0 -0
  26. {codex_stats-1.3.0 → codex_stats-1.4.2}/tests/test_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-stats
3
- Version: 1.3.0
3
+ Version: 1.4.2
4
4
  Summary: Local AI usage observability CLI for Codex sessions.
5
5
  Author: Vivek
6
6
  License: MIT
@@ -108,13 +108,21 @@ python3 -m pip install codex-stats
108
108
  - `codex-stats report weekly --format markdown`
109
109
  Generate a weekly report in Markdown.
110
110
  - `codex-stats report weekly --format html`
111
- Generate a standalone HTML report for sharing.
111
+ Generate a standalone HTML report for sharing, including inline charts.
112
+ - `codex-stats report weekly --format svg`
113
+ Generate a bundle of smaller standalone SVG assets in the current working directory, including a summary card plus graph-focused assets.
112
114
  - `codex-stats report weekly --project backend-api`
113
115
  Generate a weekly report for one project.
114
116
  - `codex-stats report weekly --format markdown --output weekly-report.md`
115
117
  Write a formatted report to a file.
116
118
  - `codex-stats report weekly --format html --output weekly-report.html`
117
119
  Write a polished standalone HTML report to a file.
120
+ - `codex-stats report weekly --format svg --output weekly-report-assets`
121
+ Write the SVG asset bundle into the output directory you provide.
122
+ - `codex-stats report weekly --format html --render`
123
+ Generate an HTML report, write it to a temp file, and open it in the default browser.
124
+ - `codex-stats report weekly --format svg --render`
125
+ Generate an SVG report, write it to a temp file, and open it in the default browser.
118
126
  - `codex-stats export codex-stats-export.json`
119
127
  Export normalized local stats to JSON.
120
128
  - `codex-stats export codex-stats-export.json --since 30d`
@@ -217,6 +225,10 @@ Then import the dashboard JSON into Grafana and point it at your Prometheus-comp
217
225
 
218
226
  The current priority list lives in [docs/roadmap.md](/Users/vivek/Desktop/Salad/codex_stats/docs/roadmap.md).
219
227
 
228
+ ## Shareable Assets
229
+
230
+ Share-ready assets now live under [docs/assets/codex-stats-share-card.svg](/Users/vivek/Desktop/Salad/codex_stats/docs/assets/codex-stats-share-card.svg), and `report --format svg` can generate a full bundle of report assets directly from local usage data.
231
+
220
232
  ## Watch Alerts
221
233
 
222
234
  `watch` can surface live warnings and critical alerts directly in the terminal dashboard.
@@ -81,13 +81,21 @@ python3 -m pip install codex-stats
81
81
  - `codex-stats report weekly --format markdown`
82
82
  Generate a weekly report in Markdown.
83
83
  - `codex-stats report weekly --format html`
84
- Generate a standalone HTML report for sharing.
84
+ Generate a standalone HTML report for sharing, including inline charts.
85
+ - `codex-stats report weekly --format svg`
86
+ Generate a bundle of smaller standalone SVG assets in the current working directory, including a summary card plus graph-focused assets.
85
87
  - `codex-stats report weekly --project backend-api`
86
88
  Generate a weekly report for one project.
87
89
  - `codex-stats report weekly --format markdown --output weekly-report.md`
88
90
  Write a formatted report to a file.
89
91
  - `codex-stats report weekly --format html --output weekly-report.html`
90
92
  Write a polished standalone HTML report to a file.
93
+ - `codex-stats report weekly --format svg --output weekly-report-assets`
94
+ Write the SVG asset bundle into the output directory you provide.
95
+ - `codex-stats report weekly --format html --render`
96
+ Generate an HTML report, write it to a temp file, and open it in the default browser.
97
+ - `codex-stats report weekly --format svg --render`
98
+ Generate an SVG report, write it to a temp file, and open it in the default browser.
91
99
  - `codex-stats export codex-stats-export.json`
92
100
  Export normalized local stats to JSON.
93
101
  - `codex-stats export codex-stats-export.json --since 30d`
@@ -190,6 +198,10 @@ Then import the dashboard JSON into Grafana and point it at your Prometheus-comp
190
198
 
191
199
  The current priority list lives in [docs/roadmap.md](/Users/vivek/Desktop/Salad/codex_stats/docs/roadmap.md).
192
200
 
201
+ ## Shareable Assets
202
+
203
+ Share-ready assets now live under [docs/assets/codex-stats-share-card.svg](/Users/vivek/Desktop/Salad/codex_stats/docs/assets/codex-stats-share-card.svg), and `report --format svg` can generate a full bundle of report assets directly from local usage data.
204
+
193
205
  ## Watch Alerts
194
206
 
195
207
  `watch` can surface live warnings and critical alerts directly in the terminal dashboard.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codex-stats"
7
- version = "1.3.0"
7
+ version = "1.4.2"
8
8
  description = "Local AI usage observability CLI for Codex sessions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "1.4.2"
@@ -3,7 +3,9 @@ from __future__ import annotations
3
3
  import argparse
4
4
  import os
5
5
  import sys
6
+ import tempfile
6
7
  import time
8
+ import webbrowser
7
9
  from datetime import datetime, timedelta
8
10
  from pathlib import Path
9
11
 
@@ -23,6 +25,8 @@ from .display import (
23
25
  format_report,
24
26
  format_report_html,
25
27
  format_report_markdown,
28
+ format_report_svg_assets,
29
+ format_report_svg,
26
30
  format_session,
27
31
  format_summary,
28
32
  format_top,
@@ -152,9 +156,10 @@ def build_parser() -> argparse.ArgumentParser:
152
156
 
153
157
  report_parser = subparsers.add_parser("report", help="Generate a shareable usage report.")
154
158
  report_parser.add_argument("period", choices=["weekly", "monthly"], help="Report period.")
155
- report_parser.add_argument("--format", choices=["text", "markdown", "html", "json"], default="text", help="Output format.")
159
+ report_parser.add_argument("--format", choices=["text", "markdown", "html", "svg", "json"], default="text", help="Output format.")
156
160
  report_parser.add_argument("--project", dest="project_name", help="Generate the report for one project.")
157
161
  report_parser.add_argument("--output", help="Write the report to a file instead of stdout.")
162
+ report_parser.add_argument("--render", action="store_true", help="Open an HTML report in the default browser.")
158
163
 
159
164
  merge_parser = subparsers.add_parser("merge", help="Merge one or more exported stats files into one snapshot.")
160
165
  merge_parser.add_argument("output", help="Merged output JSON file.")
@@ -398,20 +403,46 @@ def main(argv: list[str] | None = None) -> int:
398
403
  return 0
399
404
 
400
405
  if args.command == "report":
406
+ if args.render and args.format not in {"html", "svg"}:
407
+ print("--render requires --format html or --format svg", file=sys.stderr)
408
+ return 1
401
409
  report = build_report(paths, period=args.period, project_name=args.project_name)
410
+ daily_points = None
411
+ if args.format in {"html", "svg"}:
412
+ report_days = 7 if args.period == "weekly" else 30
413
+ report_details = filter_details_by_project(details_for_last_days(paths, report_days), args.project_name)
414
+ daily_points = summarize_daily_from_details(report_details, days=report_days)
402
415
  if args.format == "json":
403
416
  content = as_json(report.to_dict())
404
417
  elif args.format == "html":
405
- content = format_report_html(report)
418
+ content = format_report_html(report, daily_points=daily_points)
419
+ elif args.format == "svg":
420
+ assets = format_report_svg_assets(report, daily_points=daily_points)
421
+ output_dir = _write_report_svg_assets(
422
+ assets,
423
+ args.output,
424
+ base_name=_default_report_basename(args.period, args.project_name),
425
+ )
426
+ print(f"Wrote SVG assets to {output_dir}")
427
+ if args.render:
428
+ summary_path = output_dir / f"{_default_report_basename(args.period, args.project_name)}-summary-card.svg"
429
+ _open_report_in_browser(summary_path)
430
+ print(f"Opened report in browser: {summary_path}")
431
+ return 0
406
432
  elif args.format == "markdown":
407
433
  content = format_report_markdown(report)
408
434
  else:
409
435
  content = format_report(report, options)
410
- if args.output:
411
- output_path = Path(args.output).expanduser()
412
- output_path.parent.mkdir(parents=True, exist_ok=True)
413
- output_path.write_text(content + ("" if content.endswith("\n") else "\n"), encoding="utf-8")
436
+ if args.output or args.render:
437
+ output_path = _write_report_output(
438
+ content,
439
+ args.output,
440
+ suffix=f".{args.format}" if args.format in {"html", "svg"} else ".txt",
441
+ )
414
442
  print(f"Wrote report to {output_path}")
443
+ if args.render:
444
+ _open_report_in_browser(output_path)
445
+ print(f"Opened report in browser: {output_path}")
415
446
  else:
416
447
  print(content)
417
448
  return 0
@@ -585,10 +616,52 @@ def main(argv: list[str] | None = None) -> int:
585
616
  parser.print_help()
586
617
  return 1
587
618
 
619
+ def summarize_details_for_range(days: int, details):
620
+ return summarize_imported_details(details, label=f"last {max(days, 1)} days")
588
621
 
589
- if __name__ == "__main__":
590
- raise SystemExit(main())
591
622
 
623
+ def _write_report_output(content: str, output: str | None, *, suffix: str, default_name: str | None = None) -> Path:
624
+ if output:
625
+ output_path = Path(output).expanduser()
626
+ elif default_name:
627
+ output_path = Path.cwd() / default_name
628
+ else:
629
+ handle = tempfile.NamedTemporaryFile(prefix="codex-stats-report-", suffix=suffix, delete=False)
630
+ handle.close()
631
+ output_path = Path(handle.name)
632
+ output_path.parent.mkdir(parents=True, exist_ok=True)
633
+ output_path.write_text(content + ("" if content.endswith("\n") else "\n"), encoding="utf-8")
634
+ return output_path
592
635
 
593
- def summarize_details_for_range(days: int, details):
594
- return summarize_imported_details(details, label=f"last {max(days, 1)} days")
636
+
637
+ def _open_report_in_browser(path: Path) -> None:
638
+ webbrowser.open(path.resolve().as_uri())
639
+
640
+
641
+ def _default_report_filename(period: str, project_name: str | None, fmt: str) -> str:
642
+ if project_name:
643
+ safe_project = "".join(char.lower() if char.isalnum() else "-" for char in project_name).strip("-")
644
+ safe_project = safe_project or "project"
645
+ return f"codex-stats-{period}-{safe_project}.{fmt}"
646
+ return f"codex-stats-{period}.{fmt}"
647
+
648
+
649
+ def _default_report_basename(period: str, project_name: str | None) -> str:
650
+ if project_name:
651
+ safe_project = "".join(char.lower() if char.isalnum() else "-" for char in project_name).strip("-")
652
+ safe_project = safe_project or "project"
653
+ return f"codex-stats-{period}-{safe_project}"
654
+ return f"codex-stats-{period}"
655
+
656
+
657
+ def _write_report_svg_assets(assets: dict[str, str], output: str | None, *, base_name: str) -> Path:
658
+ output_dir = Path(output).expanduser() if output else Path.cwd()
659
+ output_dir.mkdir(parents=True, exist_ok=True)
660
+ for asset_name, content in assets.items():
661
+ asset_path = output_dir / f"{base_name}-{asset_name}.svg"
662
+ asset_path.write_text(content + ("" if content.endswith("\n") else "\n"), encoding="utf-8")
663
+ return output_dir
664
+
665
+
666
+ if __name__ == "__main__":
667
+ raise SystemExit(main())
@@ -308,12 +308,13 @@ def format_report_markdown(report: ReportData) -> str:
308
308
  return "\n".join(lines)
309
309
 
310
310
 
311
- def format_report_html(report: ReportData) -> str:
311
+ def format_report_html(report: ReportData, daily_points: list[DailyPoint] | None = None) -> str:
312
312
  title = f"Codex Stats {report.period.title()} Report"
313
313
  if report.project_name:
314
314
  title = f"{title}: {report.project_name}"
315
315
  delta_pct = "n/a" if report.comparison.total_tokens_delta_pct is None else f"{report.comparison.total_tokens_delta_pct:+.1f}%"
316
316
  delta_color = "var(--warn)" if delta_pct.startswith("+") else "var(--good)"
317
+ daily_points = daily_points or []
317
318
  projects_html = ""
318
319
  if report.project_name is None:
319
320
  if report.projects:
@@ -371,6 +372,30 @@ def format_report_html(report: ReportData) -> str:
371
372
 
372
373
  anomalies_html = "".join(f"<li>{escape(item)}</li>" for item in report.insights.anomalies) or "<li>none</li>"
373
374
  recommendations_html = "".join(f"<li>{escape(item)}</li>" for item in report.insights.recommendations) or "<li>none</li>"
375
+ token_trend_svg = _svg_line_chart(
376
+ [(point.day[5:], float(point.total_tokens)) for point in daily_points],
377
+ stroke="#0f766e",
378
+ fill="rgba(15, 118, 110, 0.12)",
379
+ value_formatter=lambda value: f"{int(value):,}",
380
+ )
381
+ cost_trend_svg = _svg_line_chart(
382
+ [(point.day[5:], float(point.estimated_cost_usd)) for point in daily_points],
383
+ stroke="#b45309",
384
+ fill="rgba(180, 83, 9, 0.14)",
385
+ value_formatter=lambda value: f"${value:.2f}",
386
+ )
387
+ project_share_svg = _svg_bar_chart(
388
+ [(entry.name, float(entry.total_tokens)) for entry in report.projects[:5]],
389
+ bar_color="#0f766e",
390
+ value_formatter=lambda value: f"{int(value):,}",
391
+ empty_label="No project breakdown for this scoped report.",
392
+ )
393
+ session_share_svg = _svg_bar_chart(
394
+ [(f"{entry.project_name} / {entry.model or 'unknown'}", float(entry.total_tokens)) for entry in report.top_sessions[:5]],
395
+ bar_color="#b45309",
396
+ value_formatter=lambda value: f"{int(value):,}",
397
+ empty_label="No top session data available.",
398
+ )
374
399
 
375
400
  return f"""<!DOCTYPE html>
376
401
  <html lang="en">
@@ -572,6 +597,31 @@ def format_report_html(report: ReportData) -> str:
572
597
  .table-wrap {{
573
598
  overflow-x: auto;
574
599
  }}
600
+ .chart-grid {{
601
+ display: grid;
602
+ grid-template-columns: repeat(2, minmax(0, 1fr));
603
+ gap: 18px;
604
+ }}
605
+ .chart-card {{
606
+ background: var(--panel-strong);
607
+ border: 1px solid var(--line);
608
+ border-radius: 18px;
609
+ padding: 16px;
610
+ }}
611
+ .chart-card h3 {{
612
+ margin: 0 0 10px;
613
+ font-size: 1rem;
614
+ }}
615
+ .chart-svg {{
616
+ width: 100%;
617
+ height: auto;
618
+ display: block;
619
+ }}
620
+ .chart-empty {{
621
+ color: var(--muted);
622
+ font-size: 0.95rem;
623
+ padding: 12px 0 6px;
624
+ }}
575
625
  .list-grid {{
576
626
  display: grid;
577
627
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -592,7 +642,7 @@ def format_report_html(report: ReportData) -> str:
592
642
  text-align: right;
593
643
  }}
594
644
  @media (max-width: 900px) {{
595
- .hero-grid, .split, .list-grid, .metrics {{ grid-template-columns: 1fr; }}
645
+ .hero-grid, .split, .list-grid, .metrics, .chart-grid {{ grid-template-columns: 1fr; }}
596
646
  }}
597
647
  </style>
598
648
  </head>
@@ -649,6 +699,30 @@ def format_report_html(report: ReportData) -> str:
649
699
  </div>
650
700
  </section>
651
701
 
702
+ <section class="panel">
703
+ <div class="section-header">
704
+ <h2>Charts</h2>
705
+ </div>
706
+ <div class="chart-grid">
707
+ <div class="chart-card">
708
+ <h3>Daily Token Trend</h3>
709
+ {token_trend_svg}
710
+ </div>
711
+ <div class="chart-card">
712
+ <h3>Daily Cost Trend</h3>
713
+ {cost_trend_svg}
714
+ </div>
715
+ <div class="chart-card">
716
+ <h3>Project Share</h3>
717
+ {project_share_svg}
718
+ </div>
719
+ <div class="chart-card">
720
+ <h3>Top Sessions by Tokens</h3>
721
+ {session_share_svg}
722
+ </div>
723
+ </div>
724
+ </section>
725
+
652
726
  {projects_html}
653
727
 
654
728
  <section class="panel">
@@ -714,6 +788,234 @@ def format_report_html(report: ReportData) -> str:
714
788
  """
715
789
 
716
790
 
791
+ def format_report_svg_assets(report: ReportData, daily_points: list[DailyPoint] | None = None) -> dict[str, str]:
792
+ title = f"Codex Stats {report.period.title()} Report"
793
+ if report.project_name:
794
+ title = f"{title}: {report.project_name}"
795
+ daily_points = daily_points or []
796
+ assets = {
797
+ "summary-card": _format_summary_card_svg(report, title),
798
+ "daily-tokens": _wrap_chart_svg(
799
+ title="Daily Token Trend",
800
+ subtitle=title,
801
+ chart_svg=_svg_line_chart(
802
+ [(point.day[5:], float(point.total_tokens)) for point in daily_points],
803
+ stroke="#0f766e",
804
+ fill="rgba(15, 118, 110, 0.14)",
805
+ value_formatter=lambda value: f"{int(value):,}",
806
+ ),
807
+ ),
808
+ "daily-cost": _wrap_chart_svg(
809
+ title="Daily Cost Trend",
810
+ subtitle=title,
811
+ chart_svg=_svg_line_chart(
812
+ [(point.day[5:], float(point.estimated_cost_usd)) for point in daily_points],
813
+ stroke="#b45309",
814
+ fill="rgba(180, 83, 9, 0.14)",
815
+ value_formatter=lambda value: f"${value:.2f}",
816
+ ),
817
+ ),
818
+ "project-share": _wrap_chart_svg(
819
+ title="Project Share",
820
+ subtitle=title,
821
+ chart_svg=_svg_bar_chart(
822
+ [(entry.name, float(entry.total_tokens)) for entry in report.projects[:5]],
823
+ bar_color="#0f766e",
824
+ value_formatter=lambda value: f"{int(value):,}",
825
+ empty_label="No project breakdown for this scoped report.",
826
+ ),
827
+ ),
828
+ "top-sessions": _wrap_chart_svg(
829
+ title="Top Sessions",
830
+ subtitle=title,
831
+ chart_svg=_svg_bar_chart(
832
+ [(f"{entry.project_name} / {entry.model or 'unknown'}", float(entry.total_tokens)) for entry in report.top_sessions[:5]],
833
+ bar_color="#b45309",
834
+ value_formatter=lambda value: f"{int(value):,}",
835
+ empty_label="No top session data available.",
836
+ ),
837
+ ),
838
+ }
839
+ return assets
840
+
841
+
842
+ def format_report_svg(report: ReportData, daily_points: list[DailyPoint] | None = None) -> str:
843
+ return format_report_svg_assets(report, daily_points)["summary-card"]
844
+
845
+
846
+ def _svg_line_chart(
847
+ points: list[tuple[str, float]],
848
+ *,
849
+ stroke: str,
850
+ fill: str,
851
+ value_formatter,
852
+ ) -> str:
853
+ if not points:
854
+ return '<div class="chart-empty">No data available.</div>'
855
+ width = 760
856
+ height = 240
857
+ padding_left = 48
858
+ padding_right = 20
859
+ padding_top = 20
860
+ padding_bottom = 38
861
+ values = [value for _, value in points]
862
+ max_value = max(values) if max(values) > 0 else 1.0
863
+ inner_width = width - padding_left - padding_right
864
+ inner_height = height - padding_top - padding_bottom
865
+
866
+ def point_x(index: int) -> float:
867
+ if len(points) == 1:
868
+ return padding_left + inner_width / 2
869
+ return padding_left + (index / (len(points) - 1)) * inner_width
870
+
871
+ def point_y(value: float) -> float:
872
+ return padding_top + inner_height - (value / max_value) * inner_height
873
+
874
+ line_points = " ".join(f"{point_x(index):.1f},{point_y(value):.1f}" for index, (_, value) in enumerate(points))
875
+ area_points = f"{padding_left:.1f},{padding_top + inner_height:.1f} {line_points} {padding_left + inner_width:.1f},{padding_top + inner_height:.1f}"
876
+ labels = "".join(
877
+ f'<text x="{point_x(index):.1f}" y="{height - 12}" text-anchor="middle" font-size="11" fill="#6d645d">{escape(label)}</text>'
878
+ for index, (label, _) in enumerate(points)
879
+ )
880
+ dots = "".join(
881
+ f'<circle cx="{point_x(index):.1f}" cy="{point_y(value):.1f}" r="4" fill="{stroke}"><title>{escape(label)}: {escape(value_formatter(value))}</title></circle>'
882
+ for index, (label, value) in enumerate(points)
883
+ )
884
+ grid = "".join(
885
+ f'<line x1="{padding_left}" y1="{padding_top + step * (inner_height / 3):.1f}" x2="{width - padding_right}" y2="{padding_top + step * (inner_height / 3):.1f}" stroke="rgba(72,53,36,0.12)" stroke-dasharray="4 6" />'
886
+ for step in range(4)
887
+ )
888
+ y_labels = "".join(
889
+ f'<text x="{padding_left - 8}" y="{padding_top + inner_height - step * (inner_height / 3) + 4:.1f}" text-anchor="end" font-size="11" fill="#6d645d">{escape(value_formatter((max_value / 3) * step))}</text>'
890
+ for step in range(4)
891
+ )
892
+ return (
893
+ f'<svg xmlns="http://www.w3.org/2000/svg" class="chart-svg" viewBox="0 0 {width} {height}" role="img" aria-label="Trend chart">'
894
+ f'{grid}'
895
+ f'<polygon points="{area_points}" fill="{fill}" />'
896
+ f'<polyline points="{line_points}" fill="none" stroke="{stroke}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />'
897
+ f'{dots}{labels}{y_labels}</svg>'
898
+ )
899
+
900
+
901
+ def _svg_bar_chart(
902
+ bars: list[tuple[str, float]],
903
+ *,
904
+ bar_color: str,
905
+ value_formatter,
906
+ empty_label: str,
907
+ ) -> str:
908
+ if not bars:
909
+ return f'<div class="chart-empty">{escape(empty_label)}</div>'
910
+ width = 760
911
+ row_height = 34
912
+ padding = 18
913
+ label_width = 170
914
+ bar_max_width = width - (padding * 2) - label_width - 90
915
+ height = padding * 2 + row_height * len(bars)
916
+ max_value = max(value for _, value in bars) if max(value for _, value in bars) > 0 else 1.0
917
+ rows = []
918
+ for index, (label, value) in enumerate(bars):
919
+ y = padding + index * row_height
920
+ bar_width = (value / max_value) * bar_max_width
921
+ rows.append(
922
+ f'<text x="{padding}" y="{y + 20}" font-size="12" fill="#1f1a17">{escape(label)}</text>'
923
+ f'<rect x="{padding + label_width}" y="{y + 6}" width="{bar_max_width:.1f}" height="16" rx="8" fill="rgba(72,53,36,0.08)" />'
924
+ f'<rect x="{padding + label_width}" y="{y + 6}" width="{bar_width:.1f}" height="16" rx="8" fill="{bar_color}" />'
925
+ f'<text x="{padding + label_width + bar_max_width + 10}" y="{y + 19}" font-size="12" fill="#6d645d">{escape(value_formatter(value))}</text>'
926
+ )
927
+ return f'<svg xmlns="http://www.w3.org/2000/svg" class="chart-svg" viewBox="0 0 {width} {height}" role="img" aria-label="Bar chart">{"".join(rows)}</svg>'
928
+
929
+
930
+ def _format_summary_card_svg(report: ReportData, title: str) -> str:
931
+ delta_text = "n/a" if report.comparison.total_tokens_delta_pct is None else f"{report.comparison.total_tokens_delta_pct:+.1f}%"
932
+ anomalies = report.insights.anomalies[:2] or ["No active anomalies"]
933
+ recommendations = report.insights.recommendations[:2] or ["Usage looks healthy."]
934
+ first_action = recommendations[0]
935
+ second_action = recommendations[1] if len(recommendations) > 1 else recommendations[0]
936
+ first_anomaly = anomalies[0]
937
+ second_anomaly = anomalies[1] if len(anomalies) > 1 else anomalies[0]
938
+ return f"""<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="{escape(title)}">
939
+ <defs>
940
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
941
+ <stop offset="0%" stop-color="#fbf5ec"/>
942
+ <stop offset="100%" stop-color="#efe2cf"/>
943
+ </linearGradient>
944
+ <linearGradient id="teal" x1="0%" y1="0%" x2="100%" y2="100%">
945
+ <stop offset="0%" stop-color="#115e59"/>
946
+ <stop offset="100%" stop-color="#0f766e"/>
947
+ </linearGradient>
948
+ <linearGradient id="amber" x1="0%" y1="0%" x2="100%" y2="100%">
949
+ <stop offset="0%" stop-color="#b45309"/>
950
+ <stop offset="100%" stop-color="#d97706"/>
951
+ </linearGradient>
952
+ </defs>
953
+ <rect width="1200" height="630" fill="url(#bg)"/>
954
+ <rect x="34" y="28" width="1132" height="574" rx="32" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
955
+
956
+ <circle cx="1056" cy="126" r="118" fill="rgba(15,118,110,0.10)"/>
957
+ <circle cx="1116" cy="206" r="72" fill="rgba(217,119,6,0.10)"/>
958
+ <circle cx="1012" cy="214" r="18" fill="#0f766e"/>
959
+ <circle cx="1066" cy="164" r="18" fill="#d97706"/>
960
+ <circle cx="1120" cy="114" r="18" fill="#0f766e"/>
961
+
962
+ <text x="74" y="86" font-size="18" letter-spacing="3" fill="#0f766e">CODEX STATS</text>
963
+ <text x="74" y="150" font-size="60" font-weight="700" fill="#1f1a17">{escape(title)}</text>
964
+ <text x="74" y="194" font-size="24" fill="#6d645d">Compact share card for release notes, README embeds, and social posts.</text>
965
+
966
+ <rect x="74" y="226" width="336" height="52" rx="26" fill="rgba(15,118,110,0.08)"/>
967
+ <text x="102" y="258" font-size="20" fill="#134e4a">Top model {escape(report.summary.top_model or "unknown")}</text>
968
+ <rect x="426" y="226" width="190" height="52" rx="26" fill="rgba(180,83,9,0.10)"/>
969
+ <text x="454" y="258" font-size="20" fill="#92400e">Trend {escape(delta_text)}</text>
970
+ <rect x="632" y="226" width="214" height="52" rx="26" fill="rgba(66,48,31,0.06)"/>
971
+ <text x="660" y="258" font-size="20" fill="#4b4036">Cache {escape(_fmt_percent(report.summary.cache_ratio))}</text>
972
+
973
+ <rect x="74" y="314" width="240" height="120" rx="24" fill="url(#teal)"/>
974
+ <text x="98" y="350" font-size="16" letter-spacing="2" fill="#d7faf5">TOKENS</text>
975
+ <text x="98" y="397" font-size="40" font-weight="700" fill="#ffffff">{report.summary.total_tokens:,}</text>
976
+ <text x="98" y="423" font-size="18" fill="#d7faf5">{report.summary.sessions} sessions</text>
977
+
978
+ <rect x="334" y="314" width="220" height="120" rx="24" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
979
+ <text x="358" y="350" font-size="16" letter-spacing="2" fill="#6d645d">EST. COST</text>
980
+ <text x="358" y="397" font-size="40" font-weight="700" fill="#1f1a17">${report.summary.estimated_cost_usd:.2f}</text>
981
+ <text x="358" y="423" font-size="18" fill="#6d645d">Projected ${report.costs.projected_monthly_cost_usd:.2f}</text>
982
+
983
+ <rect x="574" y="314" width="220" height="120" rx="24" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
984
+ <text x="598" y="350" font-size="16" letter-spacing="2" fill="#6d645d">REQUESTS</text>
985
+ <text x="598" y="397" font-size="40" font-weight="700" fill="#1f1a17">{report.summary.requests}</text>
986
+ <text x="598" y="423" font-size="18" fill="#6d645d">Largest {report.summary.largest_session_tokens:,}</text>
987
+
988
+ <rect x="814" y="314" width="312" height="120" rx="24" fill="url(#amber)"/>
989
+ <text x="838" y="350" font-size="16" letter-spacing="2" fill="#ffedd5">AVG / REQUEST</text>
990
+ <text x="838" y="397" font-size="40" font-weight="700" fill="#ffffff">{report.summary.average_tokens_per_request:,.0f}</text>
991
+ <text x="838" y="423" font-size="18" fill="#ffedd5">{escape(report.insights.suggestion[:34])}</text>
992
+
993
+ <rect x="74" y="466" width="492" height="94" rx="24" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
994
+ <text x="98" y="500" font-size="18" letter-spacing="2" fill="#8a4b0f">ANOMALIES</text>
995
+ <text x="98" y="530" font-size="20" fill="#8a4b0f">• {escape(first_anomaly)}</text>
996
+ <text x="98" y="556" font-size="20" fill="#8a4b0f">• {escape(second_anomaly)}</text>
997
+
998
+ <rect x="592" y="466" width="534" height="94" rx="24" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
999
+ <text x="616" y="500" font-size="18" letter-spacing="2" fill="#4b4036">DO NEXT</text>
1000
+ <text x="616" y="530" font-size="20" fill="#4b4036">• {escape(first_action)}</text>
1001
+ <text x="616" y="556" font-size="20" fill="#4b4036">• {escape(second_action)}</text>
1002
+
1003
+ <text x="1128" y="590" text-anchor="end" font-size="18" fill="#6d645d">Generated by codex-stats</text>
1004
+ </svg>"""
1005
+
1006
+
1007
+ def _wrap_chart_svg(title: str, subtitle: str, chart_svg: str) -> str:
1008
+ chart_body = chart_svg.strip()
1009
+ return f"""<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720" role="img" aria-label="{escape(title)}">
1010
+ <rect width="1200" height="720" fill="#f6efe4"/>
1011
+ <rect x="40" y="36" width="1120" height="648" rx="30" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
1012
+ <text x="72" y="94" font-size="18" letter-spacing="3" fill="#0f766e">CODEX STATS</text>
1013
+ <text x="72" y="148" font-size="42" font-weight="700" fill="#1f1a17">{escape(title)}</text>
1014
+ <text x="72" y="184" font-size="22" fill="#6d645d">{escape(subtitle)}</text>
1015
+ <g transform="translate(72 220)">{chart_body}</g>
1016
+ </svg>"""
1017
+
1018
+
717
1019
  def format_watch_dashboard(
718
1020
  summary: TimeSummary,
719
1021
  compare: CompareReport,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-stats
3
- Version: 1.3.0
3
+ Version: 1.4.2
4
4
  Summary: Local AI usage observability CLI for Codex sessions.
5
5
  Author: Vivek
6
6
  License: MIT
@@ -108,13 +108,21 @@ python3 -m pip install codex-stats
108
108
  - `codex-stats report weekly --format markdown`
109
109
  Generate a weekly report in Markdown.
110
110
  - `codex-stats report weekly --format html`
111
- Generate a standalone HTML report for sharing.
111
+ Generate a standalone HTML report for sharing, including inline charts.
112
+ - `codex-stats report weekly --format svg`
113
+ Generate a bundle of smaller standalone SVG assets in the current working directory, including a summary card plus graph-focused assets.
112
114
  - `codex-stats report weekly --project backend-api`
113
115
  Generate a weekly report for one project.
114
116
  - `codex-stats report weekly --format markdown --output weekly-report.md`
115
117
  Write a formatted report to a file.
116
118
  - `codex-stats report weekly --format html --output weekly-report.html`
117
119
  Write a polished standalone HTML report to a file.
120
+ - `codex-stats report weekly --format svg --output weekly-report-assets`
121
+ Write the SVG asset bundle into the output directory you provide.
122
+ - `codex-stats report weekly --format html --render`
123
+ Generate an HTML report, write it to a temp file, and open it in the default browser.
124
+ - `codex-stats report weekly --format svg --render`
125
+ Generate an SVG report, write it to a temp file, and open it in the default browser.
118
126
  - `codex-stats export codex-stats-export.json`
119
127
  Export normalized local stats to JSON.
120
128
  - `codex-stats export codex-stats-export.json --since 30d`
@@ -217,6 +225,10 @@ Then import the dashboard JSON into Grafana and point it at your Prometheus-comp
217
225
 
218
226
  The current priority list lives in [docs/roadmap.md](/Users/vivek/Desktop/Salad/codex_stats/docs/roadmap.md).
219
227
 
228
+ ## Shareable Assets
229
+
230
+ Share-ready assets now live under [docs/assets/codex-stats-share-card.svg](/Users/vivek/Desktop/Salad/codex_stats/docs/assets/codex-stats-share-card.svg), and `report --format svg` can generate a full bundle of report assets directly from local usage data.
231
+
220
232
  ## Watch Alerts
221
233
 
222
234
  `watch` can surface live warnings and critical alerts directly in the terminal dashboard.
@@ -1,12 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import sys
4
+ import tempfile
4
5
  import unittest
5
6
  from pathlib import Path
7
+ from unittest import mock
6
8
 
7
9
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
8
10
 
9
- from codex_stats.cli import build_parser
11
+ from codex_stats.cli import (
12
+ _default_report_basename,
13
+ _default_report_filename,
14
+ _open_report_in_browser,
15
+ _write_report_output,
16
+ _write_report_svg_assets,
17
+ build_parser,
18
+ )
10
19
 
11
20
 
12
21
  class CliTestCase(unittest.TestCase):
@@ -155,15 +164,16 @@ class CliTestCase(unittest.TestCase):
155
164
  parser = build_parser()
156
165
  init_args = parser.parse_args(["init", "--force"])
157
166
  compare_args = parser.parse_args(["compare", "today", "yesterday"])
158
- report_args = parser.parse_args(["report", "weekly", "--format", "html", "--project", "project", "--output", "weekly.html"])
167
+ report_args = parser.parse_args(["report", "weekly", "--format", "svg", "--project", "project", "--output", "weekly.svg", "--render"])
159
168
  self.assertEqual(init_args.command, "init")
160
169
  self.assertTrue(init_args.force)
161
170
  self.assertEqual(compare_args.current, "today")
162
171
  self.assertEqual(compare_args.previous, "yesterday")
163
172
  self.assertEqual(report_args.period, "weekly")
164
- self.assertEqual(report_args.format, "html")
173
+ self.assertEqual(report_args.format, "svg")
165
174
  self.assertEqual(report_args.project_name, "project")
166
- self.assertEqual(report_args.output, "weekly.html")
175
+ self.assertEqual(report_args.output, "weekly.svg")
176
+ self.assertTrue(report_args.render)
167
177
 
168
178
  def test_project_drilldown_parser(self) -> None:
169
179
  parser = build_parser()
@@ -188,6 +198,30 @@ class CliTestCase(unittest.TestCase):
188
198
  self.assertEqual(args.config_command, "show")
189
199
  self.assertTrue(args.json_output)
190
200
 
201
+ def test_write_report_output_and_browser_open(self) -> None:
202
+ with tempfile.TemporaryDirectory() as tmpdir:
203
+ output_path = _write_report_output("<html></html>", str(Path(tmpdir) / "report.html"), suffix=".html")
204
+ self.assertTrue(output_path.exists())
205
+ self.assertIn("report.html", str(output_path))
206
+ with tempfile.TemporaryDirectory() as tmpdir:
207
+ svg_dir = _write_report_svg_assets(
208
+ {"summary-card": "<svg></svg>", "daily-tokens": "<svg></svg>"},
209
+ tmpdir,
210
+ base_name="codex-stats-weekly",
211
+ )
212
+ self.assertEqual(svg_dir, Path(tmpdir))
213
+ svg_output = Path(tmpdir) / "codex-stats-weekly-summary-card.svg"
214
+ self.assertTrue(svg_output.exists())
215
+ with mock.patch("codex_stats.cli.webbrowser.open") as open_mock:
216
+ _open_report_in_browser(svg_output)
217
+ open_mock.assert_called_once()
218
+
219
+ def test_default_report_filename(self) -> None:
220
+ self.assertEqual(_default_report_filename("weekly", None, "svg"), "codex-stats-weekly.svg")
221
+ self.assertEqual(_default_report_filename("monthly", "Backend API", "svg"), "codex-stats-monthly-backend-api.svg")
222
+ self.assertEqual(_default_report_basename("weekly", None), "codex-stats-weekly")
223
+ self.assertEqual(_default_report_basename("monthly", "Backend API"), "codex-stats-monthly-backend-api")
224
+
191
225
 
192
226
  if __name__ == "__main__":
193
227
  unittest.main()
@@ -11,7 +11,7 @@ from pathlib import Path
11
11
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
12
12
 
13
13
  from codex_stats.config import Paths
14
- from codex_stats.display import format_report_html, format_watch_dashboard
14
+ from codex_stats.display import format_report_html, format_report_svg, format_report_svg_assets, format_watch_dashboard
15
15
  from codex_stats.ingest import get_session, get_session_details
16
16
  from codex_stats.metrics import (
17
17
  apply_watch_state,
@@ -445,12 +445,28 @@ class MetricsTestCase(unittest.TestCase):
445
445
  def test_report_html_output(self) -> None:
446
446
  now = datetime.fromisoformat("2026-04-03T18:30:00+05:30")
447
447
  report = build_report(self.paths, "weekly", now=now)
448
- html = format_report_html(report)
448
+ daily_points = summarize_daily(self.paths, days=7, now=now)
449
+ html = format_report_html(report, daily_points=daily_points)
449
450
  self.assertIn("<!DOCTYPE html>", html)
450
451
  self.assertIn("Codex Stats Weekly Report", html)
451
452
  self.assertIn("Top Sessions", html)
452
453
  self.assertIn("Top Projects", html)
453
454
  self.assertIn("Generated by codex-stats", html)
455
+ self.assertIn("<svg", html)
456
+ self.assertIn("Daily Token Trend", html)
457
+
458
+ def test_report_svg_output(self) -> None:
459
+ now = datetime.fromisoformat("2026-04-03T18:30:00+05:30")
460
+ report = build_report(self.paths, "weekly", now=now)
461
+ daily_points = summarize_daily(self.paths, days=7, now=now)
462
+ svg = format_report_svg(report, daily_points=daily_points)
463
+ assets = format_report_svg_assets(report, daily_points=daily_points)
464
+ self.assertIn("<svg", svg)
465
+ self.assertIn("Compact share card for release notes, README embeds, and social posts.", svg)
466
+ self.assertIn("ANOMALIES", svg)
467
+ self.assertIn("Generated by codex-stats", svg)
468
+ self.assertEqual(set(assets), {"summary-card", "daily-tokens", "daily-cost", "project-share", "top-sessions"})
469
+ self.assertIn("Daily Token Trend", assets["daily-tokens"])
454
470
 
455
471
 
456
472
  if __name__ == "__main__":
File without changes
File without changes