towow-progress 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.
- towow_progress-0.1.0/.gitignore +117 -0
- towow_progress-0.1.0/PKG-INFO +147 -0
- towow_progress-0.1.0/README.md +130 -0
- towow_progress-0.1.0/pyproject.toml +27 -0
- towow_progress-0.1.0/towow_progress/__init__.py +2 -0
- towow_progress-0.1.0/towow_progress/cli.py +128 -0
- towow_progress-0.1.0/towow_progress/config.py +51 -0
- towow_progress-0.1.0/towow_progress/detect.py +119 -0
- towow_progress-0.1.0/towow_progress/generator.py +510 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# OS files
|
|
2
|
+
.DS_Store
|
|
3
|
+
*.DS_Store
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*$py.class
|
|
9
|
+
*.so
|
|
10
|
+
.Python
|
|
11
|
+
*.egg
|
|
12
|
+
*.egg-info/
|
|
13
|
+
venv/
|
|
14
|
+
.venv/
|
|
15
|
+
*.db
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.coverage
|
|
18
|
+
htmlcov/
|
|
19
|
+
|
|
20
|
+
# Node.js
|
|
21
|
+
node_modules/
|
|
22
|
+
|
|
23
|
+
# Remotion animation project (too large)
|
|
24
|
+
towow-animation/
|
|
25
|
+
|
|
26
|
+
# Playwright MCP cache
|
|
27
|
+
.playwright-mcp/
|
|
28
|
+
|
|
29
|
+
# Worktrees (local development)
|
|
30
|
+
worktree-*/
|
|
31
|
+
.dev/worktree/
|
|
32
|
+
|
|
33
|
+
# Logs
|
|
34
|
+
*.log
|
|
35
|
+
|
|
36
|
+
# IDE
|
|
37
|
+
.idea/
|
|
38
|
+
.vscode/
|
|
39
|
+
|
|
40
|
+
# Environment files
|
|
41
|
+
.env
|
|
42
|
+
.env.local
|
|
43
|
+
|
|
44
|
+
# ML model weights (local only, too large for git)
|
|
45
|
+
backend/models/
|
|
46
|
+
|
|
47
|
+
# Data directories
|
|
48
|
+
data/
|
|
49
|
+
# Allow pre-computed vectors (production needs this, no ML framework required)
|
|
50
|
+
!data/agent_vectors.npz
|
|
51
|
+
# Allow AToA app agent data (config, not runtime)
|
|
52
|
+
!apps/*/data/
|
|
53
|
+
apps/*/data/*
|
|
54
|
+
!apps/*/data/agents.json
|
|
55
|
+
|
|
56
|
+
# Message history
|
|
57
|
+
mods/openagents.mods.workspace.messaging/
|
|
58
|
+
|
|
59
|
+
# Build outputs
|
|
60
|
+
dist/
|
|
61
|
+
build/
|
|
62
|
+
out/
|
|
63
|
+
.next/
|
|
64
|
+
|
|
65
|
+
# Archives
|
|
66
|
+
*.zip
|
|
67
|
+
|
|
68
|
+
# Prompts (proprietary — core IP, do not publish)
|
|
69
|
+
tests/crystallization_poc/prompts/
|
|
70
|
+
tests/crystallization_poc/PLAN-prompt-experiment-recruit-v1.md
|
|
71
|
+
tests/crystallization_poc/simulations/real/assemble_prompts.py
|
|
72
|
+
scenes/*/prompts/
|
|
73
|
+
docs/prompts/
|
|
74
|
+
|
|
75
|
+
# Real user experiment data (NEVER upload — contains real people's profiles and outputs)
|
|
76
|
+
tests/crystallization_poc/state.json
|
|
77
|
+
tests/crystallization_poc/simulations/real/run_*/
|
|
78
|
+
|
|
79
|
+
# Wrangler local cache (contains account credentials)
|
|
80
|
+
.wrangler/
|
|
81
|
+
|
|
82
|
+
# Guard session signals (per-session runtime artifacts, ADR-030)
|
|
83
|
+
.towow/guard/
|
|
84
|
+
|
|
85
|
+
# Separate git repositories
|
|
86
|
+
openagents/
|
|
87
|
+
backend/cache/
|
|
88
|
+
|
|
89
|
+
# Screenshots & images (local reference only)
|
|
90
|
+
*.png
|
|
91
|
+
*.jpeg
|
|
92
|
+
*.jpg
|
|
93
|
+
# Allow specific tracked images if needed
|
|
94
|
+
!website/public/**/*.png
|
|
95
|
+
!website/public/**/*.jpg
|
|
96
|
+
!website/public/**/*.svg
|
|
97
|
+
!docs/decisions/tasks/*/screenshots/*.png
|
|
98
|
+
|
|
99
|
+
# Design prototypes (local only)
|
|
100
|
+
scenes/*/demo-app/prototypes/
|
|
101
|
+
|
|
102
|
+
# Vercel build output
|
|
103
|
+
.vercel/
|
|
104
|
+
.vercelignore
|
|
105
|
+
|
|
106
|
+
# Deploy secrets
|
|
107
|
+
deploy/.env*
|
|
108
|
+
|
|
109
|
+
# Root package files (generated by accident)
|
|
110
|
+
/package.json
|
|
111
|
+
/package-lock.json
|
|
112
|
+
|
|
113
|
+
# MCP server lock file (generated locally)
|
|
114
|
+
mcp-server/package-lock.json
|
|
115
|
+
|
|
116
|
+
# Claude worktrees
|
|
117
|
+
.claude/worktrees/
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: towow-progress
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-native development progress dashboard — turn your git history into a beautiful narrative page.
|
|
5
|
+
Project-URL: Homepage, https://towow.net
|
|
6
|
+
Project-URL: Repository, https://github.com/NatureBlueee/Towow
|
|
7
|
+
Author-email: Towow <hello@towow.net>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: ai,dashboard,developer-tools,git,progress
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# towow-progress
|
|
19
|
+
|
|
20
|
+
Turn your git history into a beautiful, artistic progress page. Not a boring dashboard — a narrative that tells your project's story.
|
|
21
|
+
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install towow-progress
|
|
28
|
+
cd your-repo
|
|
29
|
+
towow-progress init # auto-detect modules, create config
|
|
30
|
+
towow-progress generate # build docs/progress.html
|
|
31
|
+
open docs/progress.html # done!
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Let Your AI Set It Up
|
|
35
|
+
|
|
36
|
+
Paste this to Claude, ChatGPT, or any coding AI:
|
|
37
|
+
|
|
38
|
+
> Install `towow-progress`, run `towow-progress init` on this repo, then customize `.towow-progress.json` — set a good title, subtitle, and adjust module names to be human-readable. Then run `towow-progress generate` and open the result.
|
|
39
|
+
|
|
40
|
+
Your AI will analyze your repo structure and configure everything.
|
|
41
|
+
|
|
42
|
+
## What You Get
|
|
43
|
+
|
|
44
|
+
- **Hero section** — total commits, lines changed, active days, longest streak
|
|
45
|
+
- **Live indicator** — latest commit with relative time ("3m ago")
|
|
46
|
+
- **Scrollable timeline** — daily commit activity with peak highlights
|
|
47
|
+
- **Module trends** — stacked area chart showing strategic focus shifts
|
|
48
|
+
- **Distribution** — horizontal bar chart of code by module
|
|
49
|
+
- **Weekly velocity** — line chart with acceleration/deceleration analysis
|
|
50
|
+
- **Changelog** — expandable recent commits with type badges, file counts, diffs
|
|
51
|
+
|
|
52
|
+
Single self-contained HTML file. Works offline. Deploy anywhere.
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
`towow-progress init` creates `.towow-progress.json`:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"title": "My Project Progress",
|
|
61
|
+
"subtitle": "Built by a team of 3 over 120 days",
|
|
62
|
+
"lang": "en",
|
|
63
|
+
"modules": {
|
|
64
|
+
"src": "Core Engine",
|
|
65
|
+
"api": "API Layer",
|
|
66
|
+
"web": "Frontend",
|
|
67
|
+
"tests": "Test Suite",
|
|
68
|
+
"docs": "Documentation"
|
|
69
|
+
},
|
|
70
|
+
"colors": {
|
|
71
|
+
"src": "#1D4ED8",
|
|
72
|
+
"api": "#059669",
|
|
73
|
+
"web": "#EA580C",
|
|
74
|
+
"tests": "#7C3AED",
|
|
75
|
+
"docs": "#0891B2"
|
|
76
|
+
},
|
|
77
|
+
"output": "docs/progress.html",
|
|
78
|
+
"accent_color": "#1D4ED8",
|
|
79
|
+
"accent_secondary": "#EA580C",
|
|
80
|
+
"background": "#F7F4F0",
|
|
81
|
+
"font_family": "",
|
|
82
|
+
"branding": true
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### What you can customize
|
|
87
|
+
|
|
88
|
+
| Field | What it does | Example |
|
|
89
|
+
|-------|-------------|---------|
|
|
90
|
+
| `title` | Big hero title | `"Acme Engine"` |
|
|
91
|
+
| `subtitle` | Line under the title | `"3 engineers · 200 days"` |
|
|
92
|
+
| `modules` | Map directory prefixes to display names | `{"src": "Core"}` |
|
|
93
|
+
| `colors` | Hex color per module | `{"src": "#1D4ED8"}` |
|
|
94
|
+
| `accent_color` | Primary brand color (borders, links, charts) | `"#8B5CF6"` (purple) |
|
|
95
|
+
| `accent_secondary` | Highlight color (peaks, badges) | `"#F59E0B"` (amber) |
|
|
96
|
+
| `background` | Page background | `"#FAFAF9"` (stone) |
|
|
97
|
+
| `font_family` | Custom font stack | `"'JetBrains Mono', monospace"` |
|
|
98
|
+
| `output` | Where to write the HTML | `"public/progress.html"` |
|
|
99
|
+
| `branding` | Show "Powered by Towow" footer | `true` / `false` |
|
|
100
|
+
|
|
101
|
+
### Color presets
|
|
102
|
+
|
|
103
|
+
**Default (warm ivory + blue)**
|
|
104
|
+
```json
|
|
105
|
+
{ "accent_color": "#1D4ED8", "accent_secondary": "#EA580C", "background": "#F7F4F0" }
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Dark academia**
|
|
109
|
+
```json
|
|
110
|
+
{ "accent_color": "#78350F", "accent_secondary": "#B45309", "background": "#FEFCE8" }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Cyberpunk**
|
|
114
|
+
```json
|
|
115
|
+
{ "accent_color": "#7C3AED", "accent_secondary": "#EC4899", "background": "#FAF5FF" }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Forest**
|
|
119
|
+
```json
|
|
120
|
+
{ "accent_color": "#065F46", "accent_secondary": "#D97706", "background": "#F0FDF4" }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Auto-Update with GitHub Actions
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
towow-progress setup-ci
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
This creates `.github/workflows/towow-progress.yml`. On every push to `main`, it regenerates and commits the HTML.
|
|
130
|
+
|
|
131
|
+
**Deploy to GitHub Pages:**
|
|
132
|
+
1. Run `towow-progress setup-ci`
|
|
133
|
+
2. Go to repo Settings → Pages → Source: `Deploy from a branch` → Branch: `main`, folder: `/docs`
|
|
134
|
+
3. Push. Your progress page is live at `https://username.github.io/repo/progress.html`
|
|
135
|
+
|
|
136
|
+
## How It Works
|
|
137
|
+
|
|
138
|
+
1. **Scans git history** — `git log --numstat` to get per-file additions/deletions
|
|
139
|
+
2. **Classifies files** — maps file paths to modules using your config
|
|
140
|
+
3. **Computes analytics** — daily/weekly/monthly aggregations, velocity, streaks
|
|
141
|
+
4. **Renders HTML** — single file with inline CSS + Chart.js from CDN
|
|
142
|
+
|
|
143
|
+
No database. No server. No API keys. Just git + Python.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
Powered by [Towow](https://towow.net) — AI-native collaboration protocol
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# towow-progress
|
|
2
|
+
|
|
3
|
+
Turn your git history into a beautiful, artistic progress page. Not a boring dashboard — a narrative that tells your project's story.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install towow-progress
|
|
11
|
+
cd your-repo
|
|
12
|
+
towow-progress init # auto-detect modules, create config
|
|
13
|
+
towow-progress generate # build docs/progress.html
|
|
14
|
+
open docs/progress.html # done!
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Let Your AI Set It Up
|
|
18
|
+
|
|
19
|
+
Paste this to Claude, ChatGPT, or any coding AI:
|
|
20
|
+
|
|
21
|
+
> Install `towow-progress`, run `towow-progress init` on this repo, then customize `.towow-progress.json` — set a good title, subtitle, and adjust module names to be human-readable. Then run `towow-progress generate` and open the result.
|
|
22
|
+
|
|
23
|
+
Your AI will analyze your repo structure and configure everything.
|
|
24
|
+
|
|
25
|
+
## What You Get
|
|
26
|
+
|
|
27
|
+
- **Hero section** — total commits, lines changed, active days, longest streak
|
|
28
|
+
- **Live indicator** — latest commit with relative time ("3m ago")
|
|
29
|
+
- **Scrollable timeline** — daily commit activity with peak highlights
|
|
30
|
+
- **Module trends** — stacked area chart showing strategic focus shifts
|
|
31
|
+
- **Distribution** — horizontal bar chart of code by module
|
|
32
|
+
- **Weekly velocity** — line chart with acceleration/deceleration analysis
|
|
33
|
+
- **Changelog** — expandable recent commits with type badges, file counts, diffs
|
|
34
|
+
|
|
35
|
+
Single self-contained HTML file. Works offline. Deploy anywhere.
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
`towow-progress init` creates `.towow-progress.json`:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"title": "My Project Progress",
|
|
44
|
+
"subtitle": "Built by a team of 3 over 120 days",
|
|
45
|
+
"lang": "en",
|
|
46
|
+
"modules": {
|
|
47
|
+
"src": "Core Engine",
|
|
48
|
+
"api": "API Layer",
|
|
49
|
+
"web": "Frontend",
|
|
50
|
+
"tests": "Test Suite",
|
|
51
|
+
"docs": "Documentation"
|
|
52
|
+
},
|
|
53
|
+
"colors": {
|
|
54
|
+
"src": "#1D4ED8",
|
|
55
|
+
"api": "#059669",
|
|
56
|
+
"web": "#EA580C",
|
|
57
|
+
"tests": "#7C3AED",
|
|
58
|
+
"docs": "#0891B2"
|
|
59
|
+
},
|
|
60
|
+
"output": "docs/progress.html",
|
|
61
|
+
"accent_color": "#1D4ED8",
|
|
62
|
+
"accent_secondary": "#EA580C",
|
|
63
|
+
"background": "#F7F4F0",
|
|
64
|
+
"font_family": "",
|
|
65
|
+
"branding": true
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### What you can customize
|
|
70
|
+
|
|
71
|
+
| Field | What it does | Example |
|
|
72
|
+
|-------|-------------|---------|
|
|
73
|
+
| `title` | Big hero title | `"Acme Engine"` |
|
|
74
|
+
| `subtitle` | Line under the title | `"3 engineers · 200 days"` |
|
|
75
|
+
| `modules` | Map directory prefixes to display names | `{"src": "Core"}` |
|
|
76
|
+
| `colors` | Hex color per module | `{"src": "#1D4ED8"}` |
|
|
77
|
+
| `accent_color` | Primary brand color (borders, links, charts) | `"#8B5CF6"` (purple) |
|
|
78
|
+
| `accent_secondary` | Highlight color (peaks, badges) | `"#F59E0B"` (amber) |
|
|
79
|
+
| `background` | Page background | `"#FAFAF9"` (stone) |
|
|
80
|
+
| `font_family` | Custom font stack | `"'JetBrains Mono', monospace"` |
|
|
81
|
+
| `output` | Where to write the HTML | `"public/progress.html"` |
|
|
82
|
+
| `branding` | Show "Powered by Towow" footer | `true` / `false` |
|
|
83
|
+
|
|
84
|
+
### Color presets
|
|
85
|
+
|
|
86
|
+
**Default (warm ivory + blue)**
|
|
87
|
+
```json
|
|
88
|
+
{ "accent_color": "#1D4ED8", "accent_secondary": "#EA580C", "background": "#F7F4F0" }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Dark academia**
|
|
92
|
+
```json
|
|
93
|
+
{ "accent_color": "#78350F", "accent_secondary": "#B45309", "background": "#FEFCE8" }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Cyberpunk**
|
|
97
|
+
```json
|
|
98
|
+
{ "accent_color": "#7C3AED", "accent_secondary": "#EC4899", "background": "#FAF5FF" }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Forest**
|
|
102
|
+
```json
|
|
103
|
+
{ "accent_color": "#065F46", "accent_secondary": "#D97706", "background": "#F0FDF4" }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Auto-Update with GitHub Actions
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
towow-progress setup-ci
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
This creates `.github/workflows/towow-progress.yml`. On every push to `main`, it regenerates and commits the HTML.
|
|
113
|
+
|
|
114
|
+
**Deploy to GitHub Pages:**
|
|
115
|
+
1. Run `towow-progress setup-ci`
|
|
116
|
+
2. Go to repo Settings → Pages → Source: `Deploy from a branch` → Branch: `main`, folder: `/docs`
|
|
117
|
+
3. Push. Your progress page is live at `https://username.github.io/repo/progress.html`
|
|
118
|
+
|
|
119
|
+
## How It Works
|
|
120
|
+
|
|
121
|
+
1. **Scans git history** — `git log --numstat` to get per-file additions/deletions
|
|
122
|
+
2. **Classifies files** — maps file paths to modules using your config
|
|
123
|
+
3. **Computes analytics** — daily/weekly/monthly aggregations, velocity, streaks
|
|
124
|
+
4. **Renders HTML** — single file with inline CSS + Chart.js from CDN
|
|
125
|
+
|
|
126
|
+
No database. No server. No API keys. Just git + Python.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
Powered by [Towow](https://towow.net) — AI-native collaboration protocol
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "towow-progress"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "AI-native development progress dashboard — turn your git history into a beautiful narrative page."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Towow", email = "hello@towow.net" }]
|
|
13
|
+
keywords = ["git", "dashboard", "progress", "ai", "developer-tools"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Topic :: Software Development :: Build Tools",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
towow-progress = "towow_progress.cli:main"
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://towow.net"
|
|
27
|
+
Repository = "https://github.com/NatureBlueee/Towow"
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""CLI entry point for towow-progress."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="towow-progress",
|
|
13
|
+
description="AI-native development progress dashboard",
|
|
14
|
+
)
|
|
15
|
+
sub = parser.add_subparsers(dest="command")
|
|
16
|
+
|
|
17
|
+
# init
|
|
18
|
+
init_p = sub.add_parser("init", help="Initialize config for your repo")
|
|
19
|
+
init_p.add_argument("--repo", type=Path, default=Path("."))
|
|
20
|
+
init_p.add_argument("--invite", type=str, help="Towow invite code")
|
|
21
|
+
|
|
22
|
+
# generate
|
|
23
|
+
gen_p = sub.add_parser("generate", help="Generate the progress page")
|
|
24
|
+
gen_p.add_argument("--repo", type=Path, default=Path("."))
|
|
25
|
+
gen_p.add_argument("--output", type=Path, default=None)
|
|
26
|
+
|
|
27
|
+
# setup-ci
|
|
28
|
+
ci_p = sub.add_parser("setup-ci", help="Create GitHub Actions workflow")
|
|
29
|
+
ci_p.add_argument("--repo", type=Path, default=Path("."))
|
|
30
|
+
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
|
|
33
|
+
if args.command == "init":
|
|
34
|
+
_cmd_init(args)
|
|
35
|
+
elif args.command == "generate":
|
|
36
|
+
_cmd_generate(args)
|
|
37
|
+
elif args.command == "setup-ci":
|
|
38
|
+
_cmd_setup_ci(args)
|
|
39
|
+
else:
|
|
40
|
+
parser.print_help()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cmd_init(args):
|
|
44
|
+
from .config import ProgressConfig
|
|
45
|
+
from .detect import detect_modules
|
|
46
|
+
|
|
47
|
+
repo = args.repo.resolve()
|
|
48
|
+
print(f"Scanning {repo} ...")
|
|
49
|
+
|
|
50
|
+
detected = detect_modules(repo)
|
|
51
|
+
config = ProgressConfig(
|
|
52
|
+
modules=detected["modules"],
|
|
53
|
+
colors=detected["colors"],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
cf = config.save(repo)
|
|
57
|
+
print(f"\n✦ Detected {len(config.modules)} modules:")
|
|
58
|
+
for k, v in config.modules.items():
|
|
59
|
+
print(f" {k}/ → {v}")
|
|
60
|
+
print(f"\nConfig: {cf}")
|
|
61
|
+
print(f"\n── AI Agent: please customize the config ──")
|
|
62
|
+
print(f"1. Read {cf} and improve: set a compelling title/subtitle,")
|
|
63
|
+
print(f" rename modules to human-readable names, pick brand colors.")
|
|
64
|
+
print(f" Available fields: title, subtitle, modules, colors,")
|
|
65
|
+
print(f" accent_color, accent_secondary, background, font_family.")
|
|
66
|
+
print(f"2. Run: towow-progress generate")
|
|
67
|
+
print(f"3. Optionally run: towow-progress setup-ci (GitHub Actions auto-update)")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cmd_generate(args):
|
|
71
|
+
from .config import ProgressConfig
|
|
72
|
+
from .generator import build_report
|
|
73
|
+
|
|
74
|
+
repo = args.repo.resolve()
|
|
75
|
+
config = ProgressConfig.load(repo)
|
|
76
|
+
output = args.output or Path(config.output)
|
|
77
|
+
|
|
78
|
+
build_report(repo, output, config)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cmd_setup_ci(args):
|
|
82
|
+
repo = args.repo.resolve()
|
|
83
|
+
wf_dir = repo / ".github" / "workflows"
|
|
84
|
+
wf_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
wf_file = wf_dir / "towow-progress.yml"
|
|
86
|
+
|
|
87
|
+
wf_file.write_text("""name: Update Progress Dashboard
|
|
88
|
+
|
|
89
|
+
on:
|
|
90
|
+
push:
|
|
91
|
+
branches: [main]
|
|
92
|
+
workflow_dispatch:
|
|
93
|
+
|
|
94
|
+
permissions:
|
|
95
|
+
contents: write
|
|
96
|
+
|
|
97
|
+
jobs:
|
|
98
|
+
update:
|
|
99
|
+
runs-on: ubuntu-latest
|
|
100
|
+
steps:
|
|
101
|
+
- uses: actions/checkout@v4
|
|
102
|
+
with:
|
|
103
|
+
fetch-depth: 0
|
|
104
|
+
|
|
105
|
+
- uses: actions/setup-python@v5
|
|
106
|
+
with:
|
|
107
|
+
python-version: "3.12"
|
|
108
|
+
|
|
109
|
+
- name: Install towow-progress
|
|
110
|
+
run: pip install towow-progress
|
|
111
|
+
|
|
112
|
+
- name: Generate dashboard
|
|
113
|
+
run: towow-progress generate
|
|
114
|
+
|
|
115
|
+
- name: Commit if changed
|
|
116
|
+
run: |
|
|
117
|
+
git config user.name "github-actions[bot]"
|
|
118
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
119
|
+
git add -A docs/progress.html
|
|
120
|
+
git diff --cached --quiet || git commit -m "chore: update progress dashboard"
|
|
121
|
+
git push
|
|
122
|
+
""")
|
|
123
|
+
print(f"Created {wf_file}")
|
|
124
|
+
print("Dashboard will auto-update on every push to main.")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Configuration for towow-progress."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
CONFIG_FILE = ".towow-progress.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ProgressConfig:
|
|
14
|
+
title: str = "Development Progress"
|
|
15
|
+
subtitle: str = ""
|
|
16
|
+
lang: str = "en"
|
|
17
|
+
modules: dict[str, str] = field(default_factory=dict)
|
|
18
|
+
colors: dict[str, str] = field(default_factory=dict)
|
|
19
|
+
output: str = "docs/progress.html"
|
|
20
|
+
branding: bool = True
|
|
21
|
+
# Customization
|
|
22
|
+
accent_color: str = "#1D4ED8" # Primary brand color (hero border, links)
|
|
23
|
+
accent_secondary: str = "#EA580C" # Secondary accent (highlights, peaks)
|
|
24
|
+
background: str = "#F7F4F0" # Page background
|
|
25
|
+
font_family: str = "" # Custom font (empty = Inter default)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def load(cls, repo: Path) -> "ProgressConfig":
|
|
29
|
+
cf = repo / CONFIG_FILE
|
|
30
|
+
if cf.exists():
|
|
31
|
+
data = json.loads(cf.read_text())
|
|
32
|
+
return cls(**{k: v for k, v in data.items()
|
|
33
|
+
if k in cls.__dataclass_fields__})
|
|
34
|
+
return cls()
|
|
35
|
+
|
|
36
|
+
def save(self, repo: Path) -> Path:
|
|
37
|
+
cf = repo / CONFIG_FILE
|
|
38
|
+
cf.write_text(json.dumps({
|
|
39
|
+
"title": self.title,
|
|
40
|
+
"subtitle": self.subtitle,
|
|
41
|
+
"lang": self.lang,
|
|
42
|
+
"modules": self.modules,
|
|
43
|
+
"colors": self.colors,
|
|
44
|
+
"output": self.output,
|
|
45
|
+
"branding": self.branding,
|
|
46
|
+
"accent_color": self.accent_color,
|
|
47
|
+
"accent_secondary": self.accent_secondary,
|
|
48
|
+
"background": self.background,
|
|
49
|
+
"font_family": self.font_family,
|
|
50
|
+
}, indent=2, ensure_ascii=False))
|
|
51
|
+
return cf
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Auto-detect repository structure and generate module mappings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# Common directory patterns → module names
|
|
10
|
+
_KNOWN_PATTERNS = {
|
|
11
|
+
"src": "Source",
|
|
12
|
+
"lib": "Library",
|
|
13
|
+
"app": "Application",
|
|
14
|
+
"api": "API",
|
|
15
|
+
"server": "Server",
|
|
16
|
+
"backend": "Backend",
|
|
17
|
+
"frontend": "Frontend",
|
|
18
|
+
"web": "Web",
|
|
19
|
+
"client": "Client",
|
|
20
|
+
"mobile": "Mobile",
|
|
21
|
+
"ios": "iOS",
|
|
22
|
+
"android": "Android",
|
|
23
|
+
"docs": "Documentation",
|
|
24
|
+
"doc": "Documentation",
|
|
25
|
+
"test": "Tests",
|
|
26
|
+
"tests": "Tests",
|
|
27
|
+
"spec": "Tests",
|
|
28
|
+
"__tests__": "Tests",
|
|
29
|
+
"e2e": "E2E Tests",
|
|
30
|
+
"scripts": "Scripts",
|
|
31
|
+
"tools": "Tools",
|
|
32
|
+
"config": "Config",
|
|
33
|
+
"infra": "Infrastructure",
|
|
34
|
+
"deploy": "Deploy",
|
|
35
|
+
"ci": "CI/CD",
|
|
36
|
+
".github": "CI/CD",
|
|
37
|
+
"packages": "Packages",
|
|
38
|
+
"modules": "Modules",
|
|
39
|
+
"components": "Components",
|
|
40
|
+
"services": "Services",
|
|
41
|
+
"models": "Models",
|
|
42
|
+
"utils": "Utilities",
|
|
43
|
+
"assets": "Assets",
|
|
44
|
+
"public": "Public",
|
|
45
|
+
"static": "Static",
|
|
46
|
+
"data": "Data",
|
|
47
|
+
"migrations": "Migrations",
|
|
48
|
+
"prisma": "Database",
|
|
49
|
+
"db": "Database",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Color palette for modules
|
|
53
|
+
_PALETTE = [
|
|
54
|
+
"#1D4ED8", "#2563EB", "#EA580C", "#059669", "#DC2626",
|
|
55
|
+
"#0891B2", "#7C3AED", "#0D9488", "#D97706", "#DB2777",
|
|
56
|
+
"#64748B", "#475569", "#9333EA", "#0284C7", "#B91C1C",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def detect_modules(repo: Path) -> dict:
|
|
61
|
+
"""Scan the repo's git history to find top-level directories and classify them.
|
|
62
|
+
|
|
63
|
+
Returns a config dict with:
|
|
64
|
+
- modules: {dir_prefix: display_name}
|
|
65
|
+
- colors: {dir_prefix: hex_color}
|
|
66
|
+
"""
|
|
67
|
+
# Get all files ever tracked by git
|
|
68
|
+
try:
|
|
69
|
+
raw = subprocess.run(
|
|
70
|
+
["git", "-C", str(repo), "log", "--all", "--name-only",
|
|
71
|
+
"--format=", "--diff-filter=ACMR"],
|
|
72
|
+
capture_output=True, text=True, check=True,
|
|
73
|
+
).stdout
|
|
74
|
+
except subprocess.CalledProcessError:
|
|
75
|
+
return _fallback_detect(repo)
|
|
76
|
+
|
|
77
|
+
# Count top-level directories
|
|
78
|
+
dir_counts: Counter = Counter()
|
|
79
|
+
for line in raw.splitlines():
|
|
80
|
+
line = line.strip()
|
|
81
|
+
if not line or "/" not in line:
|
|
82
|
+
continue
|
|
83
|
+
top = line.split("/", 1)[0]
|
|
84
|
+
if top.startswith(".") and top not in (".github",):
|
|
85
|
+
continue
|
|
86
|
+
if not top.isascii() or not all(c.isalnum() or c in "-_." for c in top):
|
|
87
|
+
continue
|
|
88
|
+
dir_counts[top] += 1
|
|
89
|
+
|
|
90
|
+
# Build module map from top dirs
|
|
91
|
+
modules = {}
|
|
92
|
+
for d, _ in dir_counts.most_common(15):
|
|
93
|
+
if d in _KNOWN_PATTERNS:
|
|
94
|
+
modules[d] = _KNOWN_PATTERNS[d]
|
|
95
|
+
else:
|
|
96
|
+
# Capitalize the directory name as display name
|
|
97
|
+
modules[d] = d.replace("_", " ").replace("-", " ").title()
|
|
98
|
+
|
|
99
|
+
# Assign colors
|
|
100
|
+
colors = {}
|
|
101
|
+
for i, d in enumerate(modules):
|
|
102
|
+
colors[d] = _PALETTE[i % len(_PALETTE)]
|
|
103
|
+
|
|
104
|
+
return {"modules": modules, "colors": colors}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _fallback_detect(repo: Path) -> dict:
|
|
108
|
+
"""Fallback: scan filesystem if git log fails."""
|
|
109
|
+
modules = {}
|
|
110
|
+
for p in sorted(repo.iterdir()):
|
|
111
|
+
if p.is_dir() and not p.name.startswith("."):
|
|
112
|
+
name = p.name
|
|
113
|
+
if name in _KNOWN_PATTERNS:
|
|
114
|
+
modules[name] = _KNOWN_PATTERNS[name]
|
|
115
|
+
elif name not in ("node_modules", "venv", ".venv", "__pycache__",
|
|
116
|
+
"dist", "build", ".next", "target"):
|
|
117
|
+
modules[name] = name.replace("_", " ").replace("-", " ").title()
|
|
118
|
+
colors = {d: _PALETTE[i % len(_PALETTE)] for i, d in enumerate(modules)}
|
|
119
|
+
return {"modules": modules, "colors": colors}
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Core report generator — config-driven, works with any repo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections import Counter, defaultdict
|
|
9
|
+
from dataclasses import dataclass, field as dc_field
|
|
10
|
+
from datetime import date, datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .config import ProgressConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Commit:
|
|
18
|
+
sha: str
|
|
19
|
+
author: str
|
|
20
|
+
authored_at: datetime
|
|
21
|
+
subject: str
|
|
22
|
+
additions: int = 0
|
|
23
|
+
deletions: int = 0
|
|
24
|
+
file_count: int = 0
|
|
25
|
+
surfaces: Counter = dc_field(default_factory=Counter)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def churn(self) -> int:
|
|
29
|
+
return self.additions + self.deletions
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_SEP = "__COMMIT__"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _git(repo: Path, *args: str) -> str:
|
|
36
|
+
return subprocess.run(
|
|
37
|
+
["git", "-C", str(repo), *args],
|
|
38
|
+
check=True, capture_output=True, text=True,
|
|
39
|
+
).stdout
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _make_classifier(config: ProgressConfig):
|
|
43
|
+
"""Build a classify function from config modules."""
|
|
44
|
+
# Sort by longest prefix first for correct matching
|
|
45
|
+
prefixes = sorted(config.modules.keys(), key=len, reverse=True)
|
|
46
|
+
|
|
47
|
+
def classify(path: str) -> str:
|
|
48
|
+
for prefix in prefixes:
|
|
49
|
+
if path.startswith(prefix + "/") or path.startswith(prefix):
|
|
50
|
+
return config.modules[prefix]
|
|
51
|
+
return "Other"
|
|
52
|
+
|
|
53
|
+
return classify
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_commits(repo: Path, classify) -> list[Commit]:
|
|
57
|
+
fmt = f"{_SEP}%n%H%x1f%an%x1f%ad%x1f%s"
|
|
58
|
+
raw = _git(repo, "log", "--use-mailmap", "--reverse",
|
|
59
|
+
"--date=iso-strict", "--numstat", f"--format={fmt}", "HEAD")
|
|
60
|
+
commits: list[Commit] = []
|
|
61
|
+
cur: Commit | None = None
|
|
62
|
+
want_meta = False
|
|
63
|
+
|
|
64
|
+
for line in raw.splitlines():
|
|
65
|
+
if line == _SEP:
|
|
66
|
+
if cur is not None:
|
|
67
|
+
commits.append(cur)
|
|
68
|
+
cur = None
|
|
69
|
+
want_meta = True
|
|
70
|
+
continue
|
|
71
|
+
if want_meta:
|
|
72
|
+
sha, author, dt_s, subj = line.split("\x1f", 3)
|
|
73
|
+
cur = Commit(sha=sha, author=author,
|
|
74
|
+
authored_at=datetime.fromisoformat(dt_s.replace("Z", "+00:00")),
|
|
75
|
+
subject=subj)
|
|
76
|
+
want_meta = False
|
|
77
|
+
continue
|
|
78
|
+
if cur is None or not line.strip():
|
|
79
|
+
continue
|
|
80
|
+
parts = line.split("\t", 2)
|
|
81
|
+
if len(parts) != 3:
|
|
82
|
+
continue
|
|
83
|
+
a_s, d_s, path = parts
|
|
84
|
+
cur.file_count += 1
|
|
85
|
+
if a_s == "-" or d_s == "-":
|
|
86
|
+
continue
|
|
87
|
+
a, d = int(a_s), int(d_s)
|
|
88
|
+
cur.additions += a
|
|
89
|
+
cur.deletions += d
|
|
90
|
+
cur.surfaces[classify(path)] += a + d
|
|
91
|
+
|
|
92
|
+
if cur is not None:
|
|
93
|
+
commits.append(cur)
|
|
94
|
+
return commits
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _relative_time(dt: datetime) -> str:
|
|
98
|
+
now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now()
|
|
99
|
+
diff = now - dt
|
|
100
|
+
minutes = int(diff.total_seconds() / 60)
|
|
101
|
+
if minutes < 1:
|
|
102
|
+
return "just now"
|
|
103
|
+
if minutes < 60:
|
|
104
|
+
return f"{minutes}m ago"
|
|
105
|
+
hours = minutes // 60
|
|
106
|
+
if hours < 24:
|
|
107
|
+
return f"{hours}h ago"
|
|
108
|
+
days = hours // 24
|
|
109
|
+
return f"{days}d ago"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _classify_type(subject: str) -> str:
|
|
113
|
+
s = subject.lower().strip()
|
|
114
|
+
for t in ("feat", "fix", "refactor", "docs", "test", "chore", "style", "perf"):
|
|
115
|
+
if s.startswith(t):
|
|
116
|
+
return t
|
|
117
|
+
return "other"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _extract_scope(subject: str) -> str:
|
|
121
|
+
m = re.match(r'\w+\(([^)]+)\)', subject)
|
|
122
|
+
return m.group(1) if m else ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def longest_streak(active_dates: set[date]) -> int:
|
|
126
|
+
if not active_dates:
|
|
127
|
+
return 0
|
|
128
|
+
d = min(active_dates)
|
|
129
|
+
end = max(active_dates)
|
|
130
|
+
best = cur = 0
|
|
131
|
+
while d <= end:
|
|
132
|
+
if d in active_dates:
|
|
133
|
+
cur += 1
|
|
134
|
+
best = max(best, cur)
|
|
135
|
+
else:
|
|
136
|
+
cur = 0
|
|
137
|
+
d += timedelta(days=1)
|
|
138
|
+
return best
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_report(repo: Path, output: Path, config: ProgressConfig) -> None:
|
|
142
|
+
classify = _make_classifier(config)
|
|
143
|
+
commits = parse_commits(repo, classify)
|
|
144
|
+
if not commits:
|
|
145
|
+
raise SystemExit("No commits found.")
|
|
146
|
+
|
|
147
|
+
first = commits[0].authored_at.date()
|
|
148
|
+
last = commits[-1].authored_at.date()
|
|
149
|
+
span = (last - first).days + 1
|
|
150
|
+
total = len(commits)
|
|
151
|
+
total_churn = sum(c.churn for c in commits)
|
|
152
|
+
|
|
153
|
+
active_set: set[date] = {c.authored_at.date() for c in commits}
|
|
154
|
+
active_days = len(active_set)
|
|
155
|
+
streak = longest_streak(active_set)
|
|
156
|
+
|
|
157
|
+
# Daily
|
|
158
|
+
daily_commits: dict[str, int] = defaultdict(int)
|
|
159
|
+
for c in commits:
|
|
160
|
+
daily_commits[c.authored_at.date().isoformat()] += 1
|
|
161
|
+
|
|
162
|
+
daily_labels, daily_values = [], []
|
|
163
|
+
d = last
|
|
164
|
+
while d >= first:
|
|
165
|
+
daily_labels.append(f"{d.month}/{d.day}")
|
|
166
|
+
daily_values.append(daily_commits.get(d.isoformat(), 0))
|
|
167
|
+
d -= timedelta(days=1)
|
|
168
|
+
|
|
169
|
+
# Monthly
|
|
170
|
+
monthly_churn: dict[str, int] = defaultdict(int)
|
|
171
|
+
for c in commits:
|
|
172
|
+
monthly_churn[c.authored_at.strftime("%Y-%m")] += c.churn
|
|
173
|
+
months = sorted(monthly_churn)
|
|
174
|
+
month_labels = [m.split("-")[1].lstrip("0") + "月" for m in months]
|
|
175
|
+
month_churn_vals = [monthly_churn[m] for m in months]
|
|
176
|
+
|
|
177
|
+
# Surface totals
|
|
178
|
+
surface_totals: Counter = Counter()
|
|
179
|
+
for c in commits:
|
|
180
|
+
surface_totals.update(c.surfaces)
|
|
181
|
+
top_surfaces = [(s, v) for s, v in surface_totals.most_common(12) if v > 0 and s != "Other"]
|
|
182
|
+
|
|
183
|
+
surface_labels = [s for s, _ in top_surfaces]
|
|
184
|
+
surface_values = [v for _, v in top_surfaces]
|
|
185
|
+
surface_colors = [config.colors.get(
|
|
186
|
+
next((k for k, v2 in config.modules.items() if v2 == s), ""),
|
|
187
|
+
"#1D4ED8"
|
|
188
|
+
) for s, _ in top_surfaces]
|
|
189
|
+
|
|
190
|
+
# Module monthly trends
|
|
191
|
+
top_keys = [s for s, _ in top_surfaces[:6]]
|
|
192
|
+
module_monthly = {s: [] for s in top_keys}
|
|
193
|
+
for m in months:
|
|
194
|
+
ms: Counter = Counter()
|
|
195
|
+
for c in commits:
|
|
196
|
+
if c.authored_at.strftime("%Y-%m") == m:
|
|
197
|
+
ms.update(c.surfaces)
|
|
198
|
+
for s in top_keys:
|
|
199
|
+
module_monthly[s].append(ms.get(s, 0))
|
|
200
|
+
|
|
201
|
+
module_trend = []
|
|
202
|
+
for s in top_keys:
|
|
203
|
+
color = config.colors.get(
|
|
204
|
+
next((k for k, v2 in config.modules.items() if v2 == s), ""), "#1D4ED8")
|
|
205
|
+
module_trend.append({
|
|
206
|
+
"label": s, "data": module_monthly[s],
|
|
207
|
+
"borderColor": color, "backgroundColor": color + "30",
|
|
208
|
+
"fill": True, "tension": 0.4, "borderWidth": 2,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
# Peak
|
|
212
|
+
peak_day_str = max(daily_commits, key=daily_commits.get)
|
|
213
|
+
peak_day_val = daily_commits[peak_day_str]
|
|
214
|
+
|
|
215
|
+
# Velocity
|
|
216
|
+
cutoff = last - timedelta(days=30)
|
|
217
|
+
recent = [c for c in commits if c.authored_at.date() > cutoff]
|
|
218
|
+
earlier = [c for c in commits if c.authored_at.date() <= cutoff]
|
|
219
|
+
rd = max(len({c.authored_at.date() for c in recent}), 1)
|
|
220
|
+
ed = max(len({c.authored_at.date() for c in earlier}), 1)
|
|
221
|
+
rv = sum(c.churn for c in recent) / rd
|
|
222
|
+
ev = sum(c.churn for c in earlier) / ed
|
|
223
|
+
vel_change = ((rv - ev) / max(ev, 1)) * 100
|
|
224
|
+
|
|
225
|
+
# Commit types
|
|
226
|
+
type_counts: Counter = Counter()
|
|
227
|
+
for c in commits:
|
|
228
|
+
type_counts[_classify_type(c.subject)] += 1
|
|
229
|
+
|
|
230
|
+
# Changelog
|
|
231
|
+
changelog = []
|
|
232
|
+
for c in reversed(commits):
|
|
233
|
+
ct = _classify_type(c.subject)
|
|
234
|
+
if ct in ("feat", "fix", "refactor", "perf"):
|
|
235
|
+
clean = re.sub(r'^\w+(\([^)]*\))?[:\s]*', '', c.subject).strip()
|
|
236
|
+
surfs = [s for s, _ in c.surfaces.most_common(3) if _ > 0]
|
|
237
|
+
changelog.append({
|
|
238
|
+
"type": ct, "scope": _extract_scope(c.subject),
|
|
239
|
+
"subject": clean[:120], "date": c.authored_at.strftime("%m/%d"),
|
|
240
|
+
"churn": c.churn, "additions": c.additions, "deletions": c.deletions,
|
|
241
|
+
"files": c.file_count, "surfaces": surfs, "sha": c.sha[:7],
|
|
242
|
+
})
|
|
243
|
+
if len(changelog) >= 20:
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
# Weekly
|
|
247
|
+
weekly: dict[str, int] = defaultdict(int)
|
|
248
|
+
for c in commits:
|
|
249
|
+
weekly[c.authored_at.strftime("%Y-W%W")] += c.churn
|
|
250
|
+
ws = sorted(weekly.keys())
|
|
251
|
+
weekly_values = [weekly[w] for w in ws]
|
|
252
|
+
weekly_labels = [f"W{w.split('W')[1]}" for w in ws]
|
|
253
|
+
|
|
254
|
+
# Authors
|
|
255
|
+
authors = set()
|
|
256
|
+
for c in commits:
|
|
257
|
+
authors.add(c.author)
|
|
258
|
+
|
|
259
|
+
chart_data = json.dumps({
|
|
260
|
+
"dailyLabels": daily_labels, "dailyValues": daily_values,
|
|
261
|
+
"monthLabels": month_labels, "monthChurn": month_churn_vals,
|
|
262
|
+
"surfaceLabels": surface_labels, "surfaceValues": surface_values,
|
|
263
|
+
"surfaceColors": surface_colors, "moduleTrend": module_trend,
|
|
264
|
+
"weeklyLabels": weekly_labels, "weeklyValues": weekly_values,
|
|
265
|
+
}, ensure_ascii=False)
|
|
266
|
+
|
|
267
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
268
|
+
|
|
269
|
+
# Resolve accent colors from config
|
|
270
|
+
accent = config.accent_color or "#1D4ED8"
|
|
271
|
+
accent2 = config.accent_secondary or "#EA580C"
|
|
272
|
+
bg = config.background or "#F7F4F0"
|
|
273
|
+
font = config.font_family or "'Inter','PingFang SC','Noto Sans SC',-apple-system,sans-serif"
|
|
274
|
+
|
|
275
|
+
html = _render(
|
|
276
|
+
config=config,
|
|
277
|
+
total=total, total_churn=total_churn,
|
|
278
|
+
active_days=active_days, span=span, streak=streak,
|
|
279
|
+
peak_day_val=peak_day_val, vel_change=vel_change,
|
|
280
|
+
recent_velocity=int(rv), type_counts=dict(type_counts.most_common()),
|
|
281
|
+
changelog=changelog, chart_data_json=chart_data,
|
|
282
|
+
generated_at=now, first_date=first, last_date=last,
|
|
283
|
+
last_commit=commits[-1], author_count=len(authors),
|
|
284
|
+
accent=accent, accent2=accent2, bg=bg, font=font,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
output.write_text(html, encoding="utf-8")
|
|
289
|
+
print(f"\n ✦ {config.title}")
|
|
290
|
+
print(f" {total:,} commits · {active_days} active days · {total_churn:,} lines changed")
|
|
291
|
+
print(f" → {output}\n")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _render(
|
|
295
|
+
config: ProgressConfig,
|
|
296
|
+
total: int, total_churn: int,
|
|
297
|
+
active_days: int, span: int, streak: int,
|
|
298
|
+
peak_day_val: int, vel_change: float,
|
|
299
|
+
recent_velocity: int, type_counts: dict,
|
|
300
|
+
changelog: list[dict], chart_data_json: str,
|
|
301
|
+
generated_at: str, first_date: date, last_date: date,
|
|
302
|
+
last_commit: Commit, author_count: int,
|
|
303
|
+
accent: str = "#1D4ED8", accent2: str = "#EA580C",
|
|
304
|
+
bg: str = "#F7F4F0", font: str = "",
|
|
305
|
+
) -> str:
|
|
306
|
+
vel_arrow = "↑" if vel_change >= 0 else "↓"
|
|
307
|
+
vel_color = "#059669" if vel_change >= 0 else "#DC2626"
|
|
308
|
+
vel_label = "accelerating" if vel_change >= 0 else "decelerating"
|
|
309
|
+
|
|
310
|
+
feat_count = type_counts.get("feat", 0)
|
|
311
|
+
fix_count = type_counts.get("fix", 0)
|
|
312
|
+
refactor_count = type_counts.get("refactor", 0)
|
|
313
|
+
last_time = _relative_time(last_commit.authored_at)
|
|
314
|
+
|
|
315
|
+
subtitle = config.subtitle or f"{author_count} contributor{'s' if author_count > 1 else ''} · {first_date.strftime('%Y.%m.%d')} — {last_date.strftime('%Y.%m.%d')}"
|
|
316
|
+
|
|
317
|
+
# Changelog HTML
|
|
318
|
+
type_badges = {
|
|
319
|
+
"feat": '<span class="badge badge-feat">NEW</span>',
|
|
320
|
+
"fix": '<span class="badge badge-fix">FIX</span>',
|
|
321
|
+
"refactor": '<span class="badge badge-refactor">REFACTOR</span>',
|
|
322
|
+
"perf": '<span class="badge badge-perf">PERF</span>',
|
|
323
|
+
}
|
|
324
|
+
cl_rows = ""
|
|
325
|
+
for e in changelog:
|
|
326
|
+
badge = type_badges.get(e["type"], "")
|
|
327
|
+
scope = f'<span class="cl-scope">{e["scope"]}</span>' if e["scope"] else ""
|
|
328
|
+
surfs = " ".join(f'<span class="cl-surf">{s}</span>' for s in e.get("surfaces", []))
|
|
329
|
+
cl_rows += f"""
|
|
330
|
+
<div class="cl-item">
|
|
331
|
+
<div class="cl-row" onclick="this.parentElement.classList.toggle('open')">
|
|
332
|
+
<div class="cl-date">{e["date"]}</div>
|
|
333
|
+
<div class="cl-badge">{badge}</div>
|
|
334
|
+
<div class="cl-body">{scope}{e["subject"]}</div>
|
|
335
|
+
<div class="cl-churn">{e["churn"]:,} lines</div>
|
|
336
|
+
<div class="cl-arrow">▾</div>
|
|
337
|
+
</div>
|
|
338
|
+
<div class="cl-detail">
|
|
339
|
+
<span class="cl-sha">{e["sha"]}</span>
|
|
340
|
+
<span class="cl-add">+{e["additions"]:,}</span>
|
|
341
|
+
<span class="cl-del">-{e["deletions"]:,}</span>
|
|
342
|
+
<span class="cl-files">{e["files"]} files</span>
|
|
343
|
+
{surfs}
|
|
344
|
+
</div>
|
|
345
|
+
</div>"""
|
|
346
|
+
|
|
347
|
+
branding = ""
|
|
348
|
+
if config.branding:
|
|
349
|
+
branding = """<div class="branding">
|
|
350
|
+
Powered by <a href="https://towow.net" target="_blank" rel="noopener">Towow</a> — AI-native collaboration protocol
|
|
351
|
+
</div>"""
|
|
352
|
+
|
|
353
|
+
# The full HTML template (same design system as Towow's own page)
|
|
354
|
+
return f"""<!DOCTYPE html>
|
|
355
|
+
<html lang="en">
|
|
356
|
+
<head>
|
|
357
|
+
<meta charset="utf-8"/>
|
|
358
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
359
|
+
<title>{config.title}</title>
|
|
360
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
|
361
|
+
<style>
|
|
362
|
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
|
363
|
+
*{{box-sizing:border-box;margin:0;padding:0}}
|
|
364
|
+
:root{{--bg:{bg};--bg-warm:#F0EBE4;--blue:{accent};--blue-deep:{accent};--blue-light:{accent}CC;--orange:{accent2};--orange-light:{accent2}AA;--text:#1E293B;--text-2:#475569;--text-3:#94A3B8;--border:rgba(30,58,138,0.08)}}
|
|
365
|
+
body{{background:var(--bg);color:var(--text);font-family:{font if font else "'Inter','PingFang SC','Noto Sans SC',-apple-system,sans-serif"};line-height:1.6;-webkit-font-smoothing:antialiased;min-height:100vh}}
|
|
366
|
+
body::before{{content:'';display:block;height:6px;background:linear-gradient(90deg,var(--blue),var(--blue-light) 40%,var(--orange) 70%,var(--orange-light))}}
|
|
367
|
+
.page{{max-width:1200px;margin:0 auto;padding:80px 48px 100px}}
|
|
368
|
+
@keyframes fadeUp{{from{{opacity:0;transform:translateY(24px)}}to{{opacity:1;transform:translateY(0)}}}}
|
|
369
|
+
.hero{{margin-bottom:96px;animation:fadeUp .8s ease-out}}
|
|
370
|
+
.hero-title{{font-size:80px;font-weight:900;letter-spacing:-.04em;line-height:1;color:var(--blue-deep);margin-bottom:16px}}
|
|
371
|
+
.hero-title .accent{{color:var(--orange)}}
|
|
372
|
+
.hero-sub{{font-size:22px;color:var(--text-2);font-weight:500;margin-bottom:48px}}
|
|
373
|
+
.hero-sub strong{{color:var(--text);font-weight:800}}
|
|
374
|
+
.live-pulse{{display:flex;align-items:center;gap:12px;margin-bottom:32px;font-size:14px;color:var(--text-3)}}
|
|
375
|
+
.pulse-dot{{width:10px;height:10px;border-radius:50%;background:#059669;position:relative}}
|
|
376
|
+
.pulse-dot::before{{content:'';position:absolute;inset:-4px;border-radius:50%;background:rgba(5,150,105,.3);animation:pulse 2s ease-in-out infinite}}
|
|
377
|
+
@keyframes pulse{{0%,100%{{transform:scale(1);opacity:.6}}50%{{transform:scale(1.8);opacity:0}}}}
|
|
378
|
+
.live-pulse .sha{{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--blue);background:rgba(29,78,216,.06);padding:2px 8px;border-radius:4px;font-weight:600}}
|
|
379
|
+
.live-pulse .msg{{color:var(--text-2);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:600px}}
|
|
380
|
+
.hero-numbers{{display:grid;grid-template-columns:repeat(4,1fr);border:2px solid var(--blue);border-radius:16px;overflow:hidden;background:#fff}}
|
|
381
|
+
.hero-num{{padding:32px 28px;border-right:1px solid var(--border);animation:fadeUp .8s ease-out backwards}}
|
|
382
|
+
.hero-num:last-child{{border-right:none}}
|
|
383
|
+
.hero-num .val{{font-size:42px;font-weight:900;letter-spacing:-.03em;color:var(--blue-deep);line-height:1.1;white-space:nowrap}}
|
|
384
|
+
.hero-num .val .unit{{font-size:20px;font-weight:700;color:var(--blue-light)}}
|
|
385
|
+
.hero-num .lbl{{font-size:15px;color:var(--text-3);font-weight:600;margin-top:4px;text-transform:uppercase;letter-spacing:.06em}}
|
|
386
|
+
.velocity-banner{{margin-top:32px;display:flex;align-items:center;gap:24px;padding:20px 28px;background:#fff;border-radius:12px;border-left:4px solid {vel_color}}}
|
|
387
|
+
.velocity-banner .vel-num{{font-size:32px;font-weight:900;color:{vel_color}}}
|
|
388
|
+
.velocity-banner .vel-detail{{font-size:16px;color:var(--text-2)}}
|
|
389
|
+
.velocity-banner .vel-detail strong{{color:var(--text)}}
|
|
390
|
+
.section{{margin-bottom:80px;animation:fadeUp .7s ease-out backwards}}
|
|
391
|
+
.section-header{{display:flex;align-items:baseline;gap:16px;margin-bottom:28px}}
|
|
392
|
+
.section-num{{font-size:14px;font-weight:800;color:var(--orange);letter-spacing:.08em}}
|
|
393
|
+
.section-title{{font-size:36px;font-weight:800;letter-spacing:-.02em;color:var(--blue-deep)}}
|
|
394
|
+
.chart-card{{background:#fff;border-radius:16px;padding:32px;border:1px solid var(--border)}}
|
|
395
|
+
.chart-wrap{{height:400px;position:relative}}
|
|
396
|
+
.chart-wrap canvas{{width:100%!important;height:100%!important}}
|
|
397
|
+
.timeline-card{{position:relative;padding:0;background:none;border:none;border-radius:0}}
|
|
398
|
+
.timeline-scroll{{overflow-x:auto;overflow-y:hidden;padding:0 0 12px;scrollbar-width:thin;scrollbar-color:var(--blue-light) transparent}}
|
|
399
|
+
.timeline-scroll::-webkit-scrollbar{{height:6px}}
|
|
400
|
+
.timeline-scroll::-webkit-scrollbar-thumb{{background:var(--blue-light);border-radius:3px}}
|
|
401
|
+
.timeline-scroll canvas{{height:720px}}
|
|
402
|
+
.grid-2{{display:grid;grid-template-columns:1fr 1fr;gap:32px}}
|
|
403
|
+
.grid-2 .chart-card{{padding:28px}}
|
|
404
|
+
.grid-2 .chart-label{{font-size:18px;font-weight:700;color:var(--text);margin-bottom:16px}}
|
|
405
|
+
.grid-2 .chart-box{{height:360px;position:relative}}
|
|
406
|
+
.grid-2 canvas{{width:100%!important;height:100%!important}}
|
|
407
|
+
.changelog{{background:#fff;border-radius:16px;border:1px solid var(--border);overflow:hidden}}
|
|
408
|
+
.cl-item{{border-bottom:1px solid rgba(0,0,0,.04)}}
|
|
409
|
+
.cl-item:last-child{{border-bottom:none}}
|
|
410
|
+
.cl-row{{display:grid;grid-template-columns:56px 90px 1fr auto 24px;align-items:center;gap:12px;padding:14px 24px;font-size:15px;cursor:pointer;transition:background .15s}}
|
|
411
|
+
.cl-row:hover{{background:rgba(29,78,216,.03)}}
|
|
412
|
+
.cl-arrow{{font-size:12px;color:var(--text-3);transition:transform .2s}}
|
|
413
|
+
.cl-item.open .cl-arrow{{transform:rotate(180deg)}}
|
|
414
|
+
.cl-detail{{display:none;padding:0 24px 14px 170px;font-size:13px;color:var(--text-3);gap:10px;align-items:center;flex-wrap:wrap}}
|
|
415
|
+
.cl-item.open .cl-detail{{display:flex}}
|
|
416
|
+
.cl-sha{{font-family:'SF Mono','Fira Code',monospace;background:rgba(29,78,216,.06);color:var(--blue);padding:2px 8px;border-radius:4px;font-weight:600}}
|
|
417
|
+
.cl-add{{color:#059669;font-weight:700}}.cl-del{{color:#DC2626;font-weight:700}}
|
|
418
|
+
.cl-files{{color:var(--text-3)}}.cl-surf{{background:var(--bg-warm);color:var(--text-2);padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600}}
|
|
419
|
+
.cl-date{{font-size:14px;color:var(--text-3);font-weight:600;font-variant-numeric:tabular-nums}}
|
|
420
|
+
.cl-body{{color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
|
421
|
+
.cl-scope{{display:inline-block;background:var(--bg-warm);color:var(--blue);font-size:12px;font-weight:700;padding:2px 8px;border-radius:4px;margin-right:8px;text-transform:uppercase}}
|
|
422
|
+
.cl-churn{{font-size:13px;color:var(--text-3);font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}}
|
|
423
|
+
.badge{{display:inline-block;padding:3px 10px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.05em}}
|
|
424
|
+
.badge-feat{{background:#DBEAFE;color:#1D4ED8}}.badge-fix{{background:#FEE2E2;color:#DC2626}}
|
|
425
|
+
.badge-refactor{{background:#EDE9FE;color:#7C3AED}}.badge-perf{{background:#CCFBF1;color:#0D9488}}
|
|
426
|
+
.type-stats{{display:flex;gap:32px;margin-top:32px}}
|
|
427
|
+
.type-stat{{display:flex;align-items:center;gap:10px;background:#fff;padding:16px 24px;border-radius:12px;border:1px solid var(--border);flex:1}}
|
|
428
|
+
.type-stat .ts-num{{font-size:28px;font-weight:900;color:var(--blue-deep)}}
|
|
429
|
+
.type-stat .ts-lbl{{font-size:14px;color:var(--text-3);font-weight:600}}
|
|
430
|
+
.divider{{height:2px;margin:0 0 80px;background:linear-gradient(90deg,var(--blue),var(--orange-light) 50%,transparent);opacity:.15}}
|
|
431
|
+
.footer{{text-align:center;color:var(--text-3);font-size:14px;padding-top:48px;border-top:1px solid var(--border)}}
|
|
432
|
+
.branding{{text-align:center;margin-top:24px;font-size:13px;color:var(--text-3)}}
|
|
433
|
+
.branding a{{color:var(--blue);text-decoration:none;font-weight:600}}
|
|
434
|
+
.branding a:hover{{text-decoration:underline}}
|
|
435
|
+
@media(max-width:900px){{.page{{padding:48px 20px 60px}}.hero-title{{font-size:48px}}.hero-numbers{{grid-template-columns:repeat(2,1fr)}}.grid-2{{grid-template-columns:1fr}}.type-stats{{flex-wrap:wrap;gap:12px}}.cl-row{{grid-template-columns:48px 70px 1fr}}.cl-churn{{display:none}}.velocity-banner{{flex-direction:column;gap:12px}}}}
|
|
436
|
+
</style>
|
|
437
|
+
</head>
|
|
438
|
+
<body>
|
|
439
|
+
<main class="page">
|
|
440
|
+
<section class="hero">
|
|
441
|
+
<h1 class="hero-title">{config.title}</h1>
|
|
442
|
+
<p class="hero-sub">{subtitle}</p>
|
|
443
|
+
<div class="live-pulse">
|
|
444
|
+
<div class="pulse-dot"></div>
|
|
445
|
+
<span>Latest commit</span>
|
|
446
|
+
<span class="sha">{last_commit.sha[:7]}</span>
|
|
447
|
+
<span class="msg">{last_commit.subject[:80]}</span>
|
|
448
|
+
<span style="margin-left:auto;font-variant-numeric:tabular-nums">{last_time}</span>
|
|
449
|
+
</div>
|
|
450
|
+
<div class="hero-numbers">
|
|
451
|
+
<div class="hero-num"><div class="val">{total:,}</div><div class="lbl">Commits</div></div>
|
|
452
|
+
<div class="hero-num"><div class="val">{total_churn:,} <span class="unit">lines</span></div><div class="lbl">Code changed</div></div>
|
|
453
|
+
<div class="hero-num"><div class="val">{active_days}<span class="unit"> / {span}</span></div><div class="lbl">Active days</div></div>
|
|
454
|
+
<div class="hero-num"><div class="val">{streak}<span class="unit"> days</span></div><div class="lbl">Best streak</div></div>
|
|
455
|
+
</div>
|
|
456
|
+
<div class="velocity-banner">
|
|
457
|
+
<div class="vel-num">{vel_arrow} {abs(vel_change):.0f}%</div>
|
|
458
|
+
<div class="vel-detail">
|
|
459
|
+
Last 30 days: <strong>{recent_velocity:,} lines/day</strong>, {vel_label}.
|
|
460
|
+
Peak day: <strong>{peak_day_val} commits</strong>.
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
</section>
|
|
464
|
+
|
|
465
|
+
<section class="section"><div class="section-header"><span class="section-num">01</span><h2 class="section-title">Commit Timeline</h2></div>
|
|
466
|
+
<div class="chart-card timeline-card"><div class="timeline-scroll" id="timelineScroll"><canvas id="dailyChart"></canvas></div></div>
|
|
467
|
+
</section><div class="divider"></div>
|
|
468
|
+
|
|
469
|
+
<section class="section"><div class="section-header"><span class="section-num">02</span><h2 class="section-title">Module Trends</h2></div>
|
|
470
|
+
<div class="chart-card"><div class="chart-wrap"><canvas id="moduleTrendChart"></canvas></div></div>
|
|
471
|
+
</section><div class="divider"></div>
|
|
472
|
+
|
|
473
|
+
<section class="section"><div class="section-header"><span class="section-num">03</span><h2 class="section-title">Distribution & Velocity</h2></div>
|
|
474
|
+
<div class="grid-2">
|
|
475
|
+
<div class="chart-card"><div class="chart-label">Code by Module</div><div class="chart-box"><canvas id="surfaceChart"></canvas></div></div>
|
|
476
|
+
<div class="chart-card"><div class="chart-label">Weekly Velocity</div><div class="chart-box"><canvas id="weeklyChart"></canvas></div></div>
|
|
477
|
+
</div>
|
|
478
|
+
</section><div class="divider"></div>
|
|
479
|
+
|
|
480
|
+
<section class="section"><div class="section-header"><span class="section-num">04</span><h2 class="section-title">Recent Activity</h2></div>
|
|
481
|
+
<div class="changelog">{cl_rows}</div>
|
|
482
|
+
<div class="type-stats">
|
|
483
|
+
<div class="type-stat"><div class="ts-num">{feat_count}</div><div class="ts-lbl">Features</div></div>
|
|
484
|
+
<div class="type-stat"><div class="ts-num">{fix_count}</div><div class="ts-lbl">Fixes</div></div>
|
|
485
|
+
<div class="type-stat"><div class="ts-num">{refactor_count}</div><div class="ts-lbl">Refactors</div></div>
|
|
486
|
+
<div class="type-stat"><div class="ts-num">{streak}</div><div class="ts-lbl">Day streak</div></div>
|
|
487
|
+
</div>
|
|
488
|
+
</section>
|
|
489
|
+
|
|
490
|
+
<footer class="footer">
|
|
491
|
+
<p>Generated {generated_at} · {first_date.isoformat()} → {last_date.isoformat()}</p>
|
|
492
|
+
{branding}
|
|
493
|
+
</footer>
|
|
494
|
+
</main>
|
|
495
|
+
<script>
|
|
496
|
+
const D={chart_data_json};
|
|
497
|
+
Chart.defaults.color='#94A3B8';Chart.defaults.borderColor='rgba(0,0,0,0.06)';
|
|
498
|
+
Chart.defaults.font.family="'Inter','PingFang SC',sans-serif";Chart.defaults.font.size=13;
|
|
499
|
+
const TT={{backgroundColor:'#1E293B',titleColor:'#F8FAFC',bodyColor:'#CBD5E1',titleFont:{{size:16,weight:'800'}},bodyFont:{{size:14}},padding:14,cornerRadius:10,borderColor:'rgba(29,78,216,0.2)',borderWidth:1,displayColors:false}};
|
|
500
|
+
(()=>{{const c=document.getElementById('dailyChart');const v=D.dailyValues;const mx=Math.max(...v);const minW=Math.max(v.length*24,1200);c.style.width=minW+'px';c.style.minWidth=minW+'px';const cols=v.map(x=>{{if(!x)return'{accent}10';const t=x/mx;return t>.8?'{accent2}':t>.5?'{accent}':`{accent}${{Math.round((.25+.55*t)*255).toString(16).padStart(2,'0')}}`}});new Chart(c,{{type:'bar',data:{{labels:D.dailyLabels,datasets:[{{data:v,backgroundColor:cols,borderRadius:5,borderSkipped:false,barPercentage:.92,categoryPercentage:.95}}]}},options:{{responsive:false,maintainAspectRatio:false,plugins:{{legend:{{display:false}},tooltip:{{...TT,callbacks:{{title:i=>i[0].label,label:i=>i.raw+' commits'}}}}}},scales:{{x:{{grid:{{display:false}},ticks:{{maxRotation:0,autoSkip:true,maxTicksLimit:14,font:{{size:16,weight:'500'}},color:'#64748B'}}}},y:{{beginAtZero:true,grid:{{color:'rgba(0,0,0,0.04)'}},ticks:{{font:{{size:18,weight:'600'}},color:'#64748B',maxTicksLimit:6}}}}}},animation:{{duration:1200,easing:'easeOutQuart'}}}}}})}}
|
|
501
|
+
)();
|
|
502
|
+
(()=>{{new Chart(document.getElementById('moduleTrendChart'),{{type:'line',data:{{labels:D.monthLabels,datasets:D.moduleTrend}},options:{{responsive:true,maintainAspectRatio:false,plugins:{{legend:{{position:'top',labels:{{usePointStyle:true,pointStyle:'circle',padding:20,font:{{size:13,weight:'600'}}}}}},tooltip:{{...TT,displayColors:true,callbacks:{{label:i=>i.dataset.label+': '+Number(i.raw).toLocaleString()+' lines'}}}}}},scales:{{x:{{grid:{{display:false}},ticks:{{font:{{size:15,weight:'700'}},color:'#64748B'}}}},y:{{stacked:true,beginAtZero:true,grid:{{color:'rgba(0,0,0,0.04)'}},ticks:{{font:{{size:13}},color:'#94A3B8',callback:v=>v>=1000?(v/1000).toFixed(0)+'K':v}}}}}},animation:{{duration:1400,easing:'easeOutQuart'}}}}}})}}
|
|
503
|
+
)();
|
|
504
|
+
(()=>{{new Chart(document.getElementById('surfaceChart'),{{type:'bar',data:{{labels:D.surfaceLabels,datasets:[{{data:D.surfaceValues,backgroundColor:D.surfaceColors,borderRadius:6,borderSkipped:false,barPercentage:.7}}]}},options:{{indexAxis:'y',responsive:true,maintainAspectRatio:false,plugins:{{legend:{{display:false}},tooltip:{{...TT,displayColors:true,callbacks:{{label:i=>Number(i.raw).toLocaleString()+' lines'}}}}}},scales:{{x:{{grid:{{color:'rgba(0,0,0,0.04)'}},ticks:{{font:{{size:13}},color:'#94A3B8',callback:v=>v>=1000?(v/1000).toFixed(0)+'K':v}}}},y:{{grid:{{display:false}},ticks:{{font:{{size:14,weight:'600'}},color:'#1E293B'}}}}}},animation:{{duration:1200,easing:'easeOutQuart'}}}}}})}}
|
|
505
|
+
)();
|
|
506
|
+
(()=>{{const c=document.getElementById('weeklyChart').getContext('2d');const g=c.createLinearGradient(0,0,0,360);g.addColorStop(0,'{accent}33');g.addColorStop(1,'{accent}05');new Chart(c,{{type:'line',data:{{labels:D.weeklyLabels,datasets:[{{data:D.weeklyValues,fill:true,backgroundColor:g,borderColor:'{accent}',borderWidth:2.5,tension:.4,pointRadius:4,pointBackgroundColor:'#fff',pointBorderColor:'{accent}',pointBorderWidth:2.5,pointHoverRadius:7,pointHoverBackgroundColor:'{accent}'}}]}},options:{{responsive:true,maintainAspectRatio:false,plugins:{{legend:{{display:false}},tooltip:{{...TT,callbacks:{{label:i=>Number(i.raw).toLocaleString()+' lines'}}}}}},scales:{{x:{{grid:{{display:false}},ticks:{{maxRotation:0,autoSkip:true,maxTicksLimit:10,font:{{size:12}},color:'#94A3B8'}}}},y:{{beginAtZero:true,grid:{{color:'rgba(0,0,0,0.04)'}},ticks:{{font:{{size:13}},color:'#94A3B8',callback:v=>v>=1000?(v/1000).toFixed(0)+'K':v}}}}}},animation:{{duration:1400,easing:'easeOutQuart'}}}}}})}}
|
|
507
|
+
)();
|
|
508
|
+
</script>
|
|
509
|
+
</body>
|
|
510
|
+
</html>"""
|