oh-my-gitstats 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 amomorning
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: oh-my-gitstats
3
+ Version: 0.1.0
4
+ Summary: Git repository commit statistics collector and visualizer
5
+ Author-email: amomorning <amomorning@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/amomorning/oh-my-gitstats
8
+ Project-URL: Repository, https://github.com/amomorning/oh-my-gitstats
9
+ Project-URL: Issues, https://github.com/amomorning/oh-my-gitstats/issues
10
+ Keywords: git,statistics,visualization,commit,heatmap,cli
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Version Control :: Git
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: click>=8.0
24
+ Requires-Dist: gitpython>=3.1
25
+ Requires-Dist: pyecharts>=2.0
26
+ Requires-Dist: jinja2>=3.0
27
+ Dynamic: license-file
28
+
29
+ <div align="center">
30
+
31
+
32
+
33
+ # πŸ“Š oh-my-gitstats
34
+
35
+ [δΈ­ζ–‡](README.zh-CN.md) | English
36
+
37
+ A Python CLI tool for collecting git commit statistics and visualizing them as interactive HTML charts.
38
+
39
+ </div>
40
+
41
+ ![Line Chart](imgs/linechart.png)
42
+ ![Heatmap](imgs/heatmap.png)
43
+
44
+ ## ✨ Features
45
+
46
+ - πŸ” **Batch Collection** - Scan multiple git repositories recursively
47
+ - ⚑ **Incremental Sync** - Only fetch new commits since last collection
48
+ - πŸ“ˆ **Line Charts** - Track changes over time with metric & granularity switching
49
+ - πŸ—“οΈ **Calendar Heatmaps** - Visualize commit activity with year-based filtering
50
+ - 🎯 **Aggregated & Individual Views** - See combined or per-repo statistics
51
+ - πŸ“‚ **VS Code Integration** - Open repo folders directly from the HTML report
52
+
53
+ ## πŸš€ Installation
54
+
55
+ ### From PyPI (Recommended)
56
+
57
+ ```bash
58
+ pip install oh-my-gitstats
59
+ ```
60
+
61
+ ### From Source (Development)
62
+
63
+ ```bash
64
+ git clone https://github.com/amomorning/oh-my-gitstats.git
65
+ cd oh-my-gitstats
66
+ pip install -e .
67
+ ```
68
+
69
+ ## πŸ“– Usage
70
+
71
+ ### 1️⃣ Collect Commit Data
72
+
73
+ Scan a directory for git repositories and export to JSON:
74
+
75
+ ```bash
76
+ gitstats collect /path/to/repos --output ./data
77
+ ```
78
+
79
+ **Options:**
80
+
81
+ | Option | Description |
82
+ |--------|-------------|
83
+ | `-o, --output` | Directory to save JSON files (default: `./data`) |
84
+ | `-q, --quiet` | Suppress output messages |
85
+
86
+ ### 2️⃣ Incremental Sync
87
+
88
+ You may collect repos from multiple locations into the same `data` directory. Re-running `collect` on every location is slow β€” `sync` reads the existing JSON files and only fetches new commits for each repo:
89
+
90
+ ```bash
91
+ # Collect from multiple locations (one-time)
92
+ gitstats collect /path/to/work-projects --output ./data
93
+ gitstats collect /path/to/personal-projects --output ./data
94
+
95
+ # Later, update all at once β€” only new commits
96
+ gitstats sync ./data
97
+ ```
98
+
99
+ **Options:**
100
+
101
+ | Option | Description |
102
+ |--------|-------------|
103
+ | `-q, --quiet` | Suppress output messages |
104
+
105
+ ### 3️⃣ Generate Visualization
106
+
107
+ Create an interactive HTML file from collected data:
108
+
109
+ ```bash
110
+ gitstats visualize ./data --output ./output/stats.html
111
+ ```
112
+
113
+ **Options:**
114
+
115
+ | Option | Description |
116
+ |--------|-------------|
117
+ | `-o, --output` | HTML file path (default: `./output/stats.html`) |
118
+
119
+ Granularity and metric can be switched dynamically in the generated HTML β€” no need to regenerate.
120
+
121
+ ## πŸ“ Output
122
+
123
+ The generated HTML contains:
124
+
125
+ 1. **πŸ“ˆ Line Chart** - Changes over time with metric selector (Lines Changed / Commit Count) and granularity selector (Day/Week/Month). Click legend to toggle projects.
126
+
127
+ 2. **πŸ—“οΈ Aggregate Heatmap** - Combined activity across all repos with year selector (All Years / specific year).
128
+
129
+ 3. **πŸ“Š Individual Heatmaps** - Per-repository calendar views in a responsive grid, each with sync status indicator and an "Open Folder" button to open in VS Code.
130
+
131
+ ![alt text](imgs/repo.png)
132
+
133
+
134
+ ## πŸ“‹ JSON Format
135
+
136
+ Each repository generates a JSON file:
137
+
138
+ ```json
139
+ {
140
+ "repo_name": "my-project",
141
+ "repo_path": "/absolute/path/to/my-project",
142
+ "last_commit_hash": "a1b2c3d4...",
143
+ "sync_status": "synced",
144
+ "commits": [
145
+ {
146
+ "timestamp": "2024-01-15T10:30:00",
147
+ "additions": 45,
148
+ "deletions": 12
149
+ }
150
+ ]
151
+ }
152
+ ```
153
+
154
+ The `last_commit_hash` field stores the HEAD commit hash at collection time. During `sync`, repositories with a matching hash are skipped instantly β€” no git operations needed.
155
+
156
+ The `sync_status` field indicates the repository's sync state with its remote:
157
+
158
+ | Status | Description |
159
+ | ------ | ----------- |
160
+ | βœ… `synced` | In sync with remote |
161
+ | ✏️ `local_changes` | Local has uncommitted changes, remote is up-to-date |
162
+ | ⬇️ `remote_ahead` | Local is clean, but remote has new commits |
163
+ | ⚠️ `diverged` | Local has uncommitted changes and remote has new commits |
164
+ | πŸ”’ `local_only_clean` | No remote configured, local is clean |
165
+ | πŸ”§ `local_only_dirty` | No remote configured, local has uncommitted changes |
166
+
167
+ ## πŸ”§ Requirements
168
+
169
+ - Python 3.9+
170
+ - click
171
+ - gitpython
172
+ - pyecharts
173
+ - jinja2
@@ -0,0 +1,145 @@
1
+ <div align="center">
2
+
3
+
4
+
5
+ # πŸ“Š oh-my-gitstats
6
+
7
+ [δΈ­ζ–‡](README.zh-CN.md) | English
8
+
9
+ A Python CLI tool for collecting git commit statistics and visualizing them as interactive HTML charts.
10
+
11
+ </div>
12
+
13
+ ![Line Chart](imgs/linechart.png)
14
+ ![Heatmap](imgs/heatmap.png)
15
+
16
+ ## ✨ Features
17
+
18
+ - πŸ” **Batch Collection** - Scan multiple git repositories recursively
19
+ - ⚑ **Incremental Sync** - Only fetch new commits since last collection
20
+ - πŸ“ˆ **Line Charts** - Track changes over time with metric & granularity switching
21
+ - πŸ—“οΈ **Calendar Heatmaps** - Visualize commit activity with year-based filtering
22
+ - 🎯 **Aggregated & Individual Views** - See combined or per-repo statistics
23
+ - πŸ“‚ **VS Code Integration** - Open repo folders directly from the HTML report
24
+
25
+ ## πŸš€ Installation
26
+
27
+ ### From PyPI (Recommended)
28
+
29
+ ```bash
30
+ pip install oh-my-gitstats
31
+ ```
32
+
33
+ ### From Source (Development)
34
+
35
+ ```bash
36
+ git clone https://github.com/amomorning/oh-my-gitstats.git
37
+ cd oh-my-gitstats
38
+ pip install -e .
39
+ ```
40
+
41
+ ## πŸ“– Usage
42
+
43
+ ### 1️⃣ Collect Commit Data
44
+
45
+ Scan a directory for git repositories and export to JSON:
46
+
47
+ ```bash
48
+ gitstats collect /path/to/repos --output ./data
49
+ ```
50
+
51
+ **Options:**
52
+
53
+ | Option | Description |
54
+ |--------|-------------|
55
+ | `-o, --output` | Directory to save JSON files (default: `./data`) |
56
+ | `-q, --quiet` | Suppress output messages |
57
+
58
+ ### 2️⃣ Incremental Sync
59
+
60
+ You may collect repos from multiple locations into the same `data` directory. Re-running `collect` on every location is slow β€” `sync` reads the existing JSON files and only fetches new commits for each repo:
61
+
62
+ ```bash
63
+ # Collect from multiple locations (one-time)
64
+ gitstats collect /path/to/work-projects --output ./data
65
+ gitstats collect /path/to/personal-projects --output ./data
66
+
67
+ # Later, update all at once β€” only new commits
68
+ gitstats sync ./data
69
+ ```
70
+
71
+ **Options:**
72
+
73
+ | Option | Description |
74
+ |--------|-------------|
75
+ | `-q, --quiet` | Suppress output messages |
76
+
77
+ ### 3️⃣ Generate Visualization
78
+
79
+ Create an interactive HTML file from collected data:
80
+
81
+ ```bash
82
+ gitstats visualize ./data --output ./output/stats.html
83
+ ```
84
+
85
+ **Options:**
86
+
87
+ | Option | Description |
88
+ |--------|-------------|
89
+ | `-o, --output` | HTML file path (default: `./output/stats.html`) |
90
+
91
+ Granularity and metric can be switched dynamically in the generated HTML β€” no need to regenerate.
92
+
93
+ ## πŸ“ Output
94
+
95
+ The generated HTML contains:
96
+
97
+ 1. **πŸ“ˆ Line Chart** - Changes over time with metric selector (Lines Changed / Commit Count) and granularity selector (Day/Week/Month). Click legend to toggle projects.
98
+
99
+ 2. **πŸ—“οΈ Aggregate Heatmap** - Combined activity across all repos with year selector (All Years / specific year).
100
+
101
+ 3. **πŸ“Š Individual Heatmaps** - Per-repository calendar views in a responsive grid, each with sync status indicator and an "Open Folder" button to open in VS Code.
102
+
103
+ ![alt text](imgs/repo.png)
104
+
105
+
106
+ ## πŸ“‹ JSON Format
107
+
108
+ Each repository generates a JSON file:
109
+
110
+ ```json
111
+ {
112
+ "repo_name": "my-project",
113
+ "repo_path": "/absolute/path/to/my-project",
114
+ "last_commit_hash": "a1b2c3d4...",
115
+ "sync_status": "synced",
116
+ "commits": [
117
+ {
118
+ "timestamp": "2024-01-15T10:30:00",
119
+ "additions": 45,
120
+ "deletions": 12
121
+ }
122
+ ]
123
+ }
124
+ ```
125
+
126
+ The `last_commit_hash` field stores the HEAD commit hash at collection time. During `sync`, repositories with a matching hash are skipped instantly β€” no git operations needed.
127
+
128
+ The `sync_status` field indicates the repository's sync state with its remote:
129
+
130
+ | Status | Description |
131
+ | ------ | ----------- |
132
+ | βœ… `synced` | In sync with remote |
133
+ | ✏️ `local_changes` | Local has uncommitted changes, remote is up-to-date |
134
+ | ⬇️ `remote_ahead` | Local is clean, but remote has new commits |
135
+ | ⚠️ `diverged` | Local has uncommitted changes and remote has new commits |
136
+ | πŸ”’ `local_only_clean` | No remote configured, local is clean |
137
+ | πŸ”§ `local_only_dirty` | No remote configured, local has uncommitted changes |
138
+
139
+ ## πŸ”§ Requirements
140
+
141
+ - Python 3.9+
142
+ - click
143
+ - gitpython
144
+ - pyecharts
145
+ - jinja2
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "oh-my-gitstats"
3
+ version = "0.1.0"
4
+ description = "Git repository commit statistics collector and visualizer"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.9"
8
+ authors = [
9
+ { name = "amomorning", email = "amomorning@gmail.com" },
10
+ ]
11
+ keywords = ["git", "statistics", "visualization", "commit", "heatmap", "cli"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.9",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Software Development :: Version Control :: Git",
22
+ ]
23
+ dependencies = [
24
+ "click>=8.0",
25
+ "gitpython>=3.1",
26
+ "pyecharts>=2.0",
27
+ "jinja2>=3.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/amomorning/oh-my-gitstats"
32
+ Repository = "https://github.com/amomorning/oh-my-gitstats"
33
+ Issues = "https://github.com/amomorning/oh-my-gitstats/issues"
34
+
35
+ [project.scripts]
36
+ gitstats = "oh_my_gitstats.cli:main"
37
+
38
+ [build-system]
39
+ requires = ["setuptools>=61.0"]
40
+ build-backend = "setuptools.build_meta"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
44
+
45
+ [tool.setuptools.package-data]
46
+ oh_my_gitstats = ["template.html"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """oh-my-gitstats: Git repository commit statistics collector and visualizer."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,245 @@
1
+ """Chart building functions using pyecharts."""
2
+
3
+ from datetime import datetime
4
+ from typing import List, Dict, Any
5
+ from collections import defaultdict
6
+
7
+ from pyecharts import options as opts
8
+ from pyecharts.charts import Line, Calendar
9
+ from pyecharts.commons.utils import JsCode
10
+
11
+ from .constants import METRICS, GRANULARITIES, COLORS
12
+ from .data import aggregate_by_period
13
+
14
+
15
+ def build_line_opts(
16
+ all_data: List[Dict[str, Any]],
17
+ granularity: str,
18
+ metric: str
19
+ ) -> str:
20
+ """Build line chart options JSON string.
21
+
22
+ Args:
23
+ all_data: List of repository data.
24
+ granularity: Time aggregation granularity (day/week/month).
25
+ metric: "changes" or "commits".
26
+
27
+ Returns:
28
+ JSON string of chart options.
29
+ """
30
+ all_periods = set()
31
+ repo_series = {}
32
+
33
+ for repo in all_data:
34
+ aggregated = aggregate_by_period(repo["commits"], granularity, metric)
35
+ repo_series[repo["repo_name"]] = aggregated
36
+ all_periods.update(aggregated.keys())
37
+
38
+ sorted_periods = sorted(all_periods)
39
+
40
+ line = Line()
41
+ line.add_xaxis(sorted_periods)
42
+
43
+ for idx, (repo_name, data) in enumerate(repo_series.items()):
44
+ y_data = [data.get(period, 0) for period in sorted_periods]
45
+ line.add_yaxis(
46
+ series_name=repo_name,
47
+ y_axis=y_data,
48
+ color=COLORS[idx % len(COLORS)],
49
+ is_smooth=True,
50
+ label_opts=opts.LabelOpts(is_show=False),
51
+ )
52
+
53
+ y_name = "Lines Changed" if metric == "changes" else "Commits"
54
+
55
+ line.set_global_opts(
56
+ tooltip_opts=opts.TooltipOpts(
57
+ trigger="axis",
58
+ formatter=JsCode(
59
+ "function(params){"
60
+ "var tip=params[0].axisValueLabel;"
61
+ "params.forEach(function(p){"
62
+ "if(p.value[1])tip+='<br/>'+p.marker+p.seriesName+': '+p.value[1];"
63
+ "});"
64
+ "return tip;}"
65
+ )
66
+ ),
67
+ legend_opts=opts.LegendOpts(
68
+ type_="scroll",
69
+ selected_mode="multiple"
70
+ ),
71
+ xaxis_opts=opts.AxisOpts(
72
+ type_="category",
73
+ axislabel_opts=opts.LabelOpts(rotate=45)
74
+ ),
75
+ yaxis_opts=opts.AxisOpts(name=y_name, type_="value"),
76
+ datazoom_opts=[
77
+ opts.DataZoomOpts(type_="inside"),
78
+ opts.DataZoomOpts(type_="slider")
79
+ ]
80
+ )
81
+
82
+ return line.dump_options()
83
+
84
+
85
+ def build_agg_heatmap_opts(
86
+ all_data: List[Dict[str, Any]],
87
+ date_range: tuple[str, str],
88
+ metric: str
89
+ ) -> str:
90
+ """Build aggregate heatmap options JSON string.
91
+
92
+ Args:
93
+ all_data: List of repository data.
94
+ date_range: Tuple of (start_date, end_date).
95
+ metric: "changes" or "commits".
96
+
97
+ Returns:
98
+ JSON string of chart options.
99
+ """
100
+ daily_data = defaultdict(int)
101
+ for repo in all_data:
102
+ for commit in repo["commits"]:
103
+ ts = datetime.fromisoformat(commit["timestamp"])
104
+ date_str = ts.strftime("%Y-%m-%d")
105
+ if metric == "commits":
106
+ daily_data[date_str] += 1
107
+ else:
108
+ daily_data[date_str] += commit["additions"] + commit["deletions"]
109
+
110
+ calendar_data = [[date, value] for date, value in sorted(daily_data.items())]
111
+
112
+ calendar = Calendar(init_opts=opts.InitOpts(width="100%", height="300px"))
113
+ calendar.add(
114
+ series_name="",
115
+ yaxis_data=calendar_data,
116
+ calendar_opts=opts.CalendarOpts(
117
+ pos_left="100px",
118
+ pos_right="50px",
119
+ pos_top="50px",
120
+ pos_bottom="20px",
121
+ range_=date_range,
122
+ yearlabel_opts=opts.CalendarYearLabelOpts(is_show=True),
123
+ monthlabel_opts=opts.CalendarMonthLabelOpts(is_show=True),
124
+ daylabel_opts=opts.CalendarDayLabelOpts(is_show=True),
125
+ )
126
+ )
127
+
128
+ unit = "lines" if metric == "changes" else "commits"
129
+ calendar.set_global_opts(
130
+ visualmap_opts=opts.VisualMapOpts(
131
+ max_=max(daily_data.values()) if daily_data else 100,
132
+ min_=0,
133
+ orient="horizontal",
134
+ is_piecewise=False,
135
+ pos_left="100px",
136
+ pos_bottom="0px",
137
+ ),
138
+ tooltip_opts=opts.TooltipOpts(formatter=JsCode(
139
+ f"function(p){{return p.data[0] + ': ' + p.data[1] + ' {unit}'}}"
140
+ ))
141
+ )
142
+
143
+ return calendar.dump_options()
144
+
145
+
146
+ def build_ind_heatmap_opts(
147
+ all_data: List[Dict[str, Any]],
148
+ date_range: tuple[str, str],
149
+ metric: str
150
+ ) -> List[str]:
151
+ """Build individual heatmap options JSON string list.
152
+
153
+ Args:
154
+ all_data: List of repository data.
155
+ date_range: Tuple of (start_date, end_date).
156
+ metric: "changes" or "commits".
157
+
158
+ Returns:
159
+ List of JSON strings, one per repository.
160
+ """
161
+ options_list = []
162
+
163
+ for repo in all_data:
164
+ daily_data = defaultdict(int)
165
+ for commit in repo["commits"]:
166
+ ts = datetime.fromisoformat(commit["timestamp"])
167
+ date_str = ts.strftime("%Y-%m-%d")
168
+ if metric == "commits":
169
+ daily_data[date_str] += 1
170
+ else:
171
+ daily_data[date_str] += commit["additions"] + commit["deletions"]
172
+
173
+ calendar_data = [[date, value] for date, value in sorted(daily_data.items())]
174
+
175
+ calendar = Calendar(init_opts=opts.InitOpts(width="100%", height="200px"))
176
+ calendar.add(
177
+ series_name="",
178
+ yaxis_data=calendar_data,
179
+ calendar_opts=opts.CalendarOpts(
180
+ pos_left="100px",
181
+ pos_right="50px",
182
+ pos_top="30px",
183
+ pos_bottom="10px",
184
+ range_=date_range,
185
+ yearlabel_opts=opts.CalendarYearLabelOpts(is_show=False),
186
+ monthlabel_opts=opts.CalendarMonthLabelOpts(is_show=True),
187
+ daylabel_opts=opts.CalendarDayLabelOpts(is_show=False),
188
+ )
189
+ )
190
+
191
+ max_val = max(daily_data.values()) if daily_data else 100
192
+ unit = "lines" if metric == "changes" else "commits"
193
+ calendar.set_global_opts(
194
+ visualmap_opts=opts.VisualMapOpts(
195
+ max_=max_val,
196
+ min_=0,
197
+ orient="horizontal",
198
+ is_piecewise=False,
199
+ pos_left="100px",
200
+ pos_bottom="0px",
201
+ ),
202
+ tooltip_opts=opts.TooltipOpts(formatter=JsCode(
203
+ f"function(p){{return p.data[0] + ': ' + p.data[1] + ' {unit}'}}"
204
+ ))
205
+ )
206
+
207
+ options_list.append(calendar.dump_options())
208
+
209
+ return options_list
210
+
211
+
212
+ def build_line_js_obj(all_data: List[Dict[str, Any]]) -> str:
213
+ """Build JS object string for line chart data (all metric x granularity combos)."""
214
+ parts = []
215
+ for metric in METRICS:
216
+ gran_parts = []
217
+ for gran in GRANULARITIES:
218
+ opts_json = build_line_opts(all_data, gran, metric)
219
+ gran_parts.append(f'"{gran}":{opts_json}')
220
+ parts.append(f'"{metric}":{{{",".join(gran_parts)}}}')
221
+ return "{" + ",".join(parts) + "}"
222
+
223
+
224
+ def build_heatmap_js_obj(
225
+ all_data: List[Dict[str, Any]],
226
+ date_range: tuple[str, str],
227
+ years: List[str]
228
+ ) -> str:
229
+ """Build JS object string for heatmap data (all metric x year combos)."""
230
+ year_ranges: Dict[str, tuple[str, str]] = {"all": date_range}
231
+ for year in years:
232
+ year_ranges[year] = (f"{year}-01-01", f"{year}-12-31")
233
+
234
+ parts = []
235
+ for metric in METRICS:
236
+ year_parts = []
237
+ for year_key, year_range in year_ranges.items():
238
+ agg = build_agg_heatmap_opts(all_data, year_range, metric)
239
+ ind_list = build_ind_heatmap_opts(all_data, year_range, metric)
240
+ ind_str = ",".join(ind_list)
241
+ year_parts.append(
242
+ f'"{year_key}":{{"aggregate":{agg},"individual":[{ind_str}]}}'
243
+ )
244
+ parts.append(f'"{metric}":{{{",".join(year_parts)}}}')
245
+ return "{" + ",".join(parts) + "}"