wukong-gitlog-cli 1.0.8 → 1.0.10
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.
- package/CHANGELOG.md +12 -0
- package/README.md +32 -34
- package/README.zh-CN.md +5 -1
- package/images/web/overtime.jpg +0 -0
- package/package.json +1 -1
- package/src/cli.mjs +87 -0
- package/src/overtime.mjs +23 -3
- package/web/app.js +112 -4
- package/web/index.html +16 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [1.0.10](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.9...v1.0.10) (2025-12-01)
|
|
6
|
+
|
|
7
|
+
### [1.0.9](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.8...v1.0.9) (2025-12-01)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* 🎸 高亮21点 ([1dbd714](https://github.com/tomatobybike/wukong-gitlog-cli/commit/1dbd714d3690e5cfcc232ab96206c553ad3b39b0))
|
|
13
|
+
* 🎸 each day overtime ([f89b935](https://github.com/tomatobybike/wukong-gitlog-cli/commit/f89b9356bc9088370ca7b1741d1fc17efa61c364))
|
|
14
|
+
* 🎸 everyday latest work ([8332341](https://github.com/tomatobybike/wukong-gitlog-cli/commit/83323414618d1e5d89203846c9feed93b32dd20a))
|
|
15
|
+
* 🎸 kpi ([6120bc1](https://github.com/tomatobybike/wukong-gitlog-cli/commit/6120bc1d0d999b5bd012109378a107d47722bbce))
|
|
16
|
+
|
|
5
17
|
### [1.0.8](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.7...v1.0.8) (2025-11-30)
|
|
6
18
|
|
|
7
19
|
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
## 📦
|
|
1
|
+
## 📦 `wukong-gitlog-cli`
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
4
|
<img src="https://raw.githubusercontent.com/tomatobybike/wukong-gitlog-cli/main/images/logo.svg" width="200" alt="wukong-dev Logo" />
|
|
@@ -14,8 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
Advanced Git commit log exporter with Excel/JSON/TXT output, grouping, stats and CLI.
|
|
16
16
|
|
|
17
|
-
English | [简体中文](./README.zh-CN.md)
|
|
18
|
-
---
|
|
17
|
+
## English | [简体中文](./README.zh-CN.md)
|
|
19
18
|
|
|
20
19
|
## Features
|
|
21
20
|
|
|
@@ -38,8 +37,6 @@ English | [简体中文](./README.zh-CN.md)
|
|
|
38
37
|
|
|
39
38
|
## Installation
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
43
40
|
Install globally to run with a short command (recommended for CLI consumers):
|
|
44
41
|
|
|
45
42
|
```bash
|
|
@@ -62,37 +59,38 @@ wukong-gitlog-cli [options]
|
|
|
62
59
|
|
|
63
60
|
Command-line options:
|
|
64
61
|
|
|
65
|
-
- `--author <name>`
|
|
66
|
-
- `--email <email>`
|
|
67
|
-
- `--since <date>`
|
|
68
|
-
- `--until <date>`
|
|
69
|
-
- `--limit <n>`
|
|
70
|
-
- `--no-merges`
|
|
71
|
-
- `--json`
|
|
72
|
-
- `--format <type>`
|
|
73
|
-
- `--group-by <type>`
|
|
74
|
-
- `--overtime`
|
|
75
|
-
- `--country <code>`
|
|
76
|
-
- `--work-start <hour>`
|
|
77
|
-
- `--work-end <hour>`
|
|
78
|
-
- `--lunch-start <hour>`
|
|
79
|
-
- `--lunch-end <hour>`
|
|
80
|
-
- `--stats`
|
|
81
|
-
- `--gerrit-api <url>`
|
|
62
|
+
- `--author <name>` Filter commits by author name
|
|
63
|
+
- `--email <email>` Filter commits by author email
|
|
64
|
+
- `--since <date>` Start date (e.g., 2025-01-01)
|
|
65
|
+
- `--until <date>` End date
|
|
66
|
+
- `--limit <n>` Limit number of commits
|
|
67
|
+
- `--no-merges` Exclude merge commits
|
|
68
|
+
- `--json` Output JSON
|
|
69
|
+
- `--format <type>` Output format: `text` | `excel` | `json` (default: `text`)
|
|
70
|
+
- `--group-by <type>` Group commits by date: `day` | `month`
|
|
71
|
+
- `--overtime` Analyze overtime culture: output counts/percentages for commits outside work hours and on non-workdays (per-person breakdown)
|
|
72
|
+
- `--country <code>` Country/region for holidays (CN|US). Default: `CN`.
|
|
73
|
+
- `--work-start <hour>` Workday start hour. Default: `9`.
|
|
74
|
+
- `--work-end <hour>` Workday end hour. Default: `18`.
|
|
75
|
+
- `--lunch-start <hour>` Lunch break start hour. Default: `12`.
|
|
76
|
+
- `--lunch-end <hour>` Lunch break end hour. Default: `14`.
|
|
77
|
+
- `--stats` Include a `Stats` sheet in the Excel export
|
|
78
|
+
- `--gerrit-api <url>` Optional: Gerrit REST API base URL for resolving `{{changeNumber}}` (e.g. `https://gerrit.example.com/gerrit`)
|
|
82
79
|
- `--gerrit-auth <token>` Optional: Authorization for Gerrit REST API (either `user:pass` for Basic or token string for Bearer)
|
|
83
|
-
- `--gerrit <prefix>`
|
|
84
|
-
- `--out <file>`
|
|
85
|
-
- `--out-dir <dir>`
|
|
86
|
-
- `--serve`
|
|
87
|
-
- `--port <n>`
|
|
88
|
-
- `--serve-only`
|
|
89
|
-
- `--version`
|
|
80
|
+
- `--gerrit <prefix>` Show Gerrit URL for each commit (supports templates `{{hash}}`, `{{changeId}}` and `{{changeNumber}}`; `{{changeId}}` falls back to `hash` when absent; `{{changeNumber}}` requires `--gerrit-api` and falls back to `changeId` or `hash`)
|
|
81
|
+
- `--out <file>` Output file name (without path). Defaults: `commits.json` / `commits.txt` / `commits.xlsx`
|
|
82
|
+
- `--out-dir <dir>` Output directory path — supports relative or absolute path, e.g., `--out-dir ../output`
|
|
83
|
+
- `--serve` Start the local web service and view the submission statistics (data files will be generated under output/data)
|
|
84
|
+
- `--port <n>` Local web service port (default: 3000)
|
|
85
|
+
- `--serve-only` Only start the web service without exporting or analyzing data (using existing data in output/data)
|
|
86
|
+
- `--version` show version information
|
|
90
87
|
|
|
91
88
|
> Output files are written to an `output/` directory in the current working directory.
|
|
92
89
|
>
|
|
93
90
|
> Tip: Use `--out-parent` or `--out-dir ../output` to write outputs into the parent folder's `output/` to avoid accidentally committing generated files to your repository.
|
|
94
91
|
|
|
95
92
|
### Per-period outputs
|
|
93
|
+
|
|
96
94
|
You can generate per-month and per-week outputs under `output/month/` and `output/week/` using the `--per-period-formats` option. Example:
|
|
97
95
|
|
|
98
96
|
```bash
|
|
@@ -113,6 +111,7 @@ wukong-gitlog-cli --overtime --limit 200 --format text --out commits.txt --per-p
|
|
|
113
111
|
```
|
|
114
112
|
|
|
115
113
|
### Serve a local dashboard
|
|
114
|
+
|
|
116
115
|
You can start a small static web dashboard to visualize commit statistics and charts. It will export raw commits and analyzed stats into `output/data/` as `commits.mjs` and `overtime-stats.mjs`, and start a local web server serving `web/` and `output/data/`:
|
|
117
116
|
|
|
118
117
|
```bash
|
|
@@ -128,7 +127,9 @@ wukong-gitlog-cli --serve --port 8080 --overtime --limit 200 --out commits.txt
|
|
|
128
127
|
|
|
129
128
|
Open `http://localhost:3000` to view the dashboard.
|
|
130
129
|
|
|
131
|
-
|
|
130
|
+
<p align="center">
|
|
131
|
+
<img src="https://raw.githubusercontent.com/tomatobybike/wukong-gitlog-cli/main/images/web/overtime.jpg" width="200" alt="wukong-dev Logo" />
|
|
132
|
+
</p>
|
|
132
133
|
|
|
133
134
|
---
|
|
134
135
|
|
|
@@ -209,8 +210,6 @@ wukong-gitlog-cli --out-dir ../output --format text --limit 5 --out custom1.txt
|
|
|
209
210
|
|
|
210
211
|
---
|
|
211
212
|
|
|
212
|
-
|
|
213
|
-
|
|
214
213
|
```bash
|
|
215
214
|
wukong-gitlog-cli --overtime --limit 500
|
|
216
215
|
```
|
|
@@ -220,7 +219,7 @@ wukong-gitlog-cli --overtime --limit 500
|
|
|
220
219
|
- The CLI prints helpful messages after exporting files and writes outputs to the `output/` folder in the repo root.
|
|
221
220
|
- Internally `src/utils/index.mjs` acts as a barrel that re-exports helper functions located in `src/utils/`.
|
|
222
221
|
- If you plan to reuse the helpers in other modules, import from `./src/utils/index.mjs` explicitly.
|
|
223
|
-
- The Excel export uses `exceljs` and adds an
|
|
222
|
+
- The Excel export uses `exceljs` and adds an `autoFilter` to the sheet header.
|
|
224
223
|
|
|
225
224
|
Suggested `.gitignore` snippet (to avoid accidentally committing generated files):
|
|
226
225
|
|
|
@@ -241,4 +240,3 @@ PRs are welcome — add tests and keep changes modular. If you add new CLI flags
|
|
|
241
240
|
## License
|
|
242
241
|
|
|
243
242
|
MIT
|
|
244
|
-
|
package/README.zh-CN.md
CHANGED
|
@@ -118,7 +118,7 @@ wukong-gitlog-cli --per-period-only
|
|
|
118
118
|
启动服务器:
|
|
119
119
|
|
|
120
120
|
```bash
|
|
121
|
-
wukong-gitlog-cli --serve --
|
|
121
|
+
wukong-gitlog-cli --overtime --serve --port 5555 --limit 200
|
|
122
122
|
```
|
|
123
123
|
|
|
124
124
|
浏览器访问:
|
|
@@ -127,6 +127,10 @@ wukong-gitlog-cli --serve --overtime --limit 200
|
|
|
127
127
|
http://localhost:3000
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
+
|
|
131
|
+
<p align="center">
|
|
132
|
+
<img src="https://raw.githubusercontent.com/tomatobybike/wukong-gitlog-cli/main/images/web/overtime.jpg" width="200" alt="wukong-dev Logo" />
|
|
133
|
+
</p>
|
|
130
134
|
---
|
|
131
135
|
|
|
132
136
|
## 🔗 Gerrit 支持
|
|
Binary file
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk'
|
|
2
2
|
import { Command } from 'commander'
|
|
3
|
+
import dayjs from 'dayjs'
|
|
3
4
|
import fs from 'fs'
|
|
4
5
|
import path from 'path'
|
|
5
6
|
import { fileURLToPath } from 'url'
|
|
@@ -105,6 +106,12 @@ const main = async () => {
|
|
|
105
106
|
(v) => parseInt(v, 10),
|
|
106
107
|
14
|
|
107
108
|
)
|
|
109
|
+
.option(
|
|
110
|
+
'--overnight-cutoff <hour>',
|
|
111
|
+
'次日凌晨归并窗口(小时),默认 6',
|
|
112
|
+
(v) => parseInt(v, 10),
|
|
113
|
+
6
|
|
114
|
+
)
|
|
108
115
|
.option('--out <file>', '输出文件名(不含路径)')
|
|
109
116
|
.option(
|
|
110
117
|
'--out-dir <dir>',
|
|
@@ -351,6 +358,86 @@ const main = async () => {
|
|
|
351
358
|
writeTextFile(dataWeeklyFile, weeklyModule)
|
|
352
359
|
console.log(chalk.green(`Weekly series 已导出: ${dataWeeklyFile}`))
|
|
353
360
|
|
|
361
|
+
// 新增:每月趋势数据(用于前端图表)
|
|
362
|
+
const monthGroups2 = groupRecords(records, 'month')
|
|
363
|
+
const monthKeys2 = Object.keys(monthGroups2).sort()
|
|
364
|
+
const monthlySeries = monthKeys2.map((k) => {
|
|
365
|
+
const s = analyzeOvertime(monthGroups2[k], {
|
|
366
|
+
startHour:
|
|
367
|
+
opts.workStart || opts.workStart === 0 ? opts.workStart : 9,
|
|
368
|
+
endHour: opts.workEnd || opts.workEnd === 0 ? opts.workEnd : 18,
|
|
369
|
+
lunchStart:
|
|
370
|
+
opts.lunchStart || opts.lunchStart === 0 ? opts.lunchStart : 12,
|
|
371
|
+
lunchEnd: opts.lunchEnd || opts.lunchEnd === 0 ? opts.lunchEnd : 14,
|
|
372
|
+
country: opts.country || 'CN'
|
|
373
|
+
})
|
|
374
|
+
return {
|
|
375
|
+
period: k,
|
|
376
|
+
total: s.total,
|
|
377
|
+
outsideWorkCount: s.outsideWorkCount,
|
|
378
|
+
outsideWorkRate: s.outsideWorkRate,
|
|
379
|
+
nonWorkdayCount: s.nonWorkdayCount,
|
|
380
|
+
nonWorkdayRate: s.nonWorkdayRate
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
const dataMonthlyFile = outputFilePath('data/overtime-monthly.mjs', outDir)
|
|
384
|
+
const monthlyModule = `export default ${JSON.stringify(monthlySeries, null, 2)};\n`
|
|
385
|
+
writeTextFile(dataMonthlyFile, monthlyModule)
|
|
386
|
+
console.log(chalk.green(`Monthly series 已导出: ${dataMonthlyFile}`))
|
|
387
|
+
|
|
388
|
+
// 新增:每日最晚提交小时(用于显著展示加班严重程度)
|
|
389
|
+
const dayGroups2 = groupRecords(records, 'day')
|
|
390
|
+
const dayKeys2 = Object.keys(dayGroups2).sort()
|
|
391
|
+
const overnightCutoff = Number.isFinite(opts.overnightCutoff) ? opts.overnightCutoff : 6
|
|
392
|
+
const latestByDay = dayKeys2.map((k) => {
|
|
393
|
+
const list = dayGroups2[k]
|
|
394
|
+
const vals = list
|
|
395
|
+
.map((r) => ({ r, _dt: new Date(r.date) }))
|
|
396
|
+
.filter((x) => x._dt && !Number.isNaN(x._dt.valueOf()))
|
|
397
|
+
.sort((a, b) => a._dt.valueOf() - b._dt.valueOf())
|
|
398
|
+
const last = vals.length > 0 ? vals[vals.length - 1] : null
|
|
399
|
+
const hour = last ? new Date(last.r.date).getHours() : null
|
|
400
|
+
const nextKey = dayjs(k).add(1, 'day').format('YYYY-MM-DD')
|
|
401
|
+
const early = dayGroups2[nextKey] || []
|
|
402
|
+
const earlyHours = early
|
|
403
|
+
.map((r) => new Date(r.date))
|
|
404
|
+
.filter((d) => !Number.isNaN(d.valueOf()))
|
|
405
|
+
.map((d) => d.getHours())
|
|
406
|
+
.filter((h) => h >= 0 && h < overnightCutoff)
|
|
407
|
+
const earlyMax = earlyHours.length > 0 ? Math.max(...earlyHours) : null
|
|
408
|
+
const normalized = typeof earlyMax === 'number' ? 24 + earlyMax : null
|
|
409
|
+
const latestHourNormalized = Math.max(
|
|
410
|
+
typeof hour === 'number' ? hour : -1,
|
|
411
|
+
typeof normalized === 'number' ? normalized : -1
|
|
412
|
+
)
|
|
413
|
+
return { date: k, latestHour: hour, latestHourNormalized: latestHourNormalized >= 0 ? latestHourNormalized : null }
|
|
414
|
+
})
|
|
415
|
+
const dataLatestByDayFile = outputFilePath(
|
|
416
|
+
'data/overtime-latest-by-day.mjs',
|
|
417
|
+
outDir
|
|
418
|
+
)
|
|
419
|
+
const latestByDayModule = `export default ${JSON.stringify(latestByDay, null, 2)};\n`
|
|
420
|
+
writeTextFile(dataLatestByDayFile, latestByDayModule)
|
|
421
|
+
console.log(
|
|
422
|
+
chalk.green(`Latest-by-day series 已导出: ${dataLatestByDayFile}`)
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
// 导出配置(供前端显示)
|
|
426
|
+
try {
|
|
427
|
+
const configFile = outputFilePath('data/config.mjs', outDir)
|
|
428
|
+
const cfg = {
|
|
429
|
+
startHour: opts.workStart || 9,
|
|
430
|
+
endHour: opts.workEnd || 18,
|
|
431
|
+
lunchStart: opts.lunchStart || 12,
|
|
432
|
+
lunchEnd: opts.lunchEnd || 14,
|
|
433
|
+
overnightCutoff
|
|
434
|
+
}
|
|
435
|
+
writeTextFile(configFile, `export default ${JSON.stringify(cfg, null, 2)};\n`)
|
|
436
|
+
console.log(chalk.green(`Config 已导出: ${configFile}`))
|
|
437
|
+
} catch (e) {
|
|
438
|
+
console.warn('Export config failed:', e && e.message ? e.message : e)
|
|
439
|
+
}
|
|
440
|
+
|
|
354
441
|
startServer(opts.port || 3000, outDir).catch(() => {})
|
|
355
442
|
} catch (err) {
|
|
356
443
|
console.warn(
|
package/src/overtime.mjs
CHANGED
|
@@ -47,6 +47,8 @@ export function analyzeOvertime(records, opts = {}) {
|
|
|
47
47
|
let outsideWorkCount = 0;
|
|
48
48
|
let nonWorkdayCount = 0;
|
|
49
49
|
let holidayCount = 0;
|
|
50
|
+
let nightOutsideCount = 0;
|
|
51
|
+
let overtimeSeveritySum = 0;
|
|
50
52
|
|
|
51
53
|
const byAuthor = new Map();
|
|
52
54
|
|
|
@@ -54,6 +56,8 @@ export function analyzeOvertime(records, opts = {}) {
|
|
|
54
56
|
let endCommit = null;
|
|
55
57
|
let latestCommit = null;
|
|
56
58
|
let latestOutsideCommit = null;
|
|
59
|
+
let latestCommitHour = null;
|
|
60
|
+
let latestOutsideCommitHour = null;
|
|
57
61
|
|
|
58
62
|
// init holiday checker for country
|
|
59
63
|
const hd = new DateHolidays();
|
|
@@ -87,9 +91,15 @@ export function analyzeOvertime(records, opts = {}) {
|
|
|
87
91
|
|
|
88
92
|
if (outside) {
|
|
89
93
|
outsideWorkCount++;
|
|
94
|
+
if (hour >= endHour || hour < startHour) {
|
|
95
|
+
nightOutsideCount++;
|
|
96
|
+
const sev = hour >= endHour ? (hour - endHour) : (24 - endHour + hour);
|
|
97
|
+
overtimeSeveritySum += sev;
|
|
98
|
+
}
|
|
90
99
|
// 记录最新的加班提交
|
|
91
100
|
if (!latestOutsideCommit || dt.isAfter(parseCommitDate(latestOutsideCommit.date))) {
|
|
92
101
|
latestOutsideCommit = r;
|
|
102
|
+
latestOutsideCommitHour = dt.hour();
|
|
93
103
|
}
|
|
94
104
|
}
|
|
95
105
|
if (isNonWork) nonWorkdayCount++;
|
|
@@ -137,6 +147,7 @@ export function analyzeOvertime(records, opts = {}) {
|
|
|
137
147
|
[startCommit] = validRecords;
|
|
138
148
|
endCommit = validRecords[validRecords.length - 1];
|
|
139
149
|
latestCommit = endCommit;
|
|
150
|
+
latestCommitHour = parseCommitDate(latestCommit.date).hour();
|
|
140
151
|
// cleanup temp _dt
|
|
141
152
|
for (let i = 0; i < validRecords.length; i++) {
|
|
142
153
|
// copy the object without _dt for safety
|
|
@@ -157,12 +168,18 @@ export function analyzeOvertime(records, opts = {}) {
|
|
|
157
168
|
nonWorkdayCount,
|
|
158
169
|
outsideWorkRate: total ? +(outsideWorkCount / total).toFixed(3) : 0,
|
|
159
170
|
nonWorkdayRate: total ? +(nonWorkdayCount / total).toFixed(3) : 0,
|
|
171
|
+
nightOutsideCount,
|
|
172
|
+
nightOutsideRate: total ? +(nightOutsideCount / total).toFixed(3) : 0,
|
|
173
|
+
overtimeSeveritySum,
|
|
174
|
+
overtimeSeverityAvg: nightOutsideCount ? +(overtimeSeveritySum / nightOutsideCount).toFixed(2) : 0,
|
|
160
175
|
perAuthor,
|
|
161
176
|
/// 提示:计算 min/max 日期 & latest commit
|
|
162
177
|
startCommit: startCommit || null,
|
|
163
178
|
endCommit: endCommit || null,
|
|
164
179
|
latestCommit: latestCommit || null,
|
|
165
180
|
latestOutsideCommit: latestOutsideCommit || null,
|
|
181
|
+
latestCommitHour,
|
|
182
|
+
latestOutsideCommitHour,
|
|
166
183
|
startHour,
|
|
167
184
|
endHour,
|
|
168
185
|
lunchStart,
|
|
@@ -178,8 +195,8 @@ export function analyzeOvertime(records, opts = {}) {
|
|
|
178
195
|
}
|
|
179
196
|
|
|
180
197
|
export function renderOvertimeText(stats) {
|
|
181
|
-
const { total, outsideWorkCount, nonWorkdayCount, holidayCount, outsideWorkRate, nonWorkdayRate, holidayRate, perAuthor, startHour, endHour, lunchStart, lunchEnd, country, hourlyOvertimeCommits = [], hourlyOvertimePercent = [] } = stats;
|
|
182
|
-
const { startCommit, endCommit, latestCommit, latestOutsideCommit } = stats;
|
|
198
|
+
const { total, outsideWorkCount, nonWorkdayCount, holidayCount, outsideWorkRate, nonWorkdayRate, holidayRate, nightOutsideCount = 0, nightOutsideRate = 0, overtimeSeverityAvg = 0, perAuthor, startHour, endHour, lunchStart, lunchEnd, country, hourlyOvertimeCommits = [], hourlyOvertimePercent = [] } = stats;
|
|
199
|
+
const { startCommit, endCommit, latestCommit, latestOutsideCommit, latestCommitHour, latestOutsideCommitHour } = stats;
|
|
183
200
|
const lines = [];
|
|
184
201
|
|
|
185
202
|
const formatPercent = (v) => `${(v * 100).toFixed(1)}%`;
|
|
@@ -213,6 +230,7 @@ export function renderOvertimeText(stats) {
|
|
|
213
230
|
lines.push(` Author : ${latestCommit.author}`);
|
|
214
231
|
lines.push(` Date : ${formatDateForCountry(latestCommit.date, country)}`);
|
|
215
232
|
lines.push(` Message: ${latestCommit.message}`);
|
|
233
|
+
if (typeof latestCommitHour === 'number') lines.push(` Hour : ${String(latestCommitHour).padStart(2, '0')}:00`);
|
|
216
234
|
}
|
|
217
235
|
if (latestOutsideCommit) {
|
|
218
236
|
lines.push('加班最晚的一次提交:');
|
|
@@ -220,12 +238,15 @@ export function renderOvertimeText(stats) {
|
|
|
220
238
|
lines.push(` Author : ${latestOutsideCommit.author}`);
|
|
221
239
|
lines.push(` Date : ${formatDateForCountry(latestOutsideCommit.date, country)}`);
|
|
222
240
|
lines.push(` Message: ${latestOutsideCommit.message}`);
|
|
241
|
+
const h = parseCommitDate(latestOutsideCommit.date).hour();
|
|
242
|
+
lines.push(` Hour : ${String(h).padStart(2, '0')}:00`);
|
|
223
243
|
}
|
|
224
244
|
// country: holiday region, lunchStart/lunchEnd define midday break
|
|
225
245
|
lines.push(`下班时间定义:${startHour}:00 - ${endHour}:00 (午休 ${lunchStart}:00 - ${lunchEnd}:00)`);
|
|
226
246
|
lines.push(`国家假期参考:${String(country).toUpperCase()},节假日提交数:${holidayCount},占比:${(holidayRate * 100).toFixed(1)}%`);
|
|
227
247
|
lines.push(`下班时间(工作时间外)提交数:${outsideWorkCount},占比:${(outsideWorkRate * 100).toFixed(1)}%`);
|
|
228
248
|
lines.push(`非工作日(周末)提交数:${nonWorkdayCount},占比:${(nonWorkdayRate * 100).toFixed(1)}%`);
|
|
249
|
+
lines.push(`晚间加班(${endHour}:00–次日${startHour}:00)提交数:${nightOutsideCount},占比:${(nightOutsideRate * 100).toFixed(1)}%,平均超过下班:${overtimeSeverityAvg} 小时`);
|
|
229
250
|
lines.push('');
|
|
230
251
|
lines.push('每小时加班分布(工作时间外提交数/占比):');
|
|
231
252
|
const hourLine = ' Hour | OvertimeCount | Percent';
|
|
@@ -317,4 +338,3 @@ export function renderOvertimeCsv(stats) {
|
|
|
317
338
|
});
|
|
318
339
|
return rows.join('\n');
|
|
319
340
|
}
|
|
320
|
-
|
package/web/app.js
CHANGED
|
@@ -3,18 +3,24 @@ const formatDate = (d) => new Date(d).toLocaleString();
|
|
|
3
3
|
|
|
4
4
|
async function loadData() {
|
|
5
5
|
try {
|
|
6
|
-
const [commitsModule, statsModule, weeklyModule] = await Promise.all([
|
|
6
|
+
const [commitsModule, statsModule, weeklyModule, monthlyModule, latestByDayModule, configModule] = await Promise.all([
|
|
7
7
|
import("/data/commits.mjs"),
|
|
8
8
|
import("/data/overtime-stats.mjs"),
|
|
9
9
|
import("/data/overtime-weekly.mjs"),
|
|
10
|
+
import("/data/overtime-monthly.mjs").catch(() => ({ default: [] })),
|
|
11
|
+
import("/data/overtime-latest-by-day.mjs").catch(() => ({ default: [] })),
|
|
12
|
+
import("/data/config.mjs").catch(() => ({ default: {} })),
|
|
10
13
|
]);
|
|
11
14
|
const commits = commitsModule.default || [];
|
|
12
15
|
const stats = statsModule.default || {};
|
|
13
16
|
const weekly = weeklyModule.default || [];
|
|
14
|
-
|
|
17
|
+
const monthly = monthlyModule.default || [];
|
|
18
|
+
const latestByDay = latestByDayModule.default || [];
|
|
19
|
+
const config = configModule.default || {};
|
|
20
|
+
return { commits, stats, weekly, monthly, latestByDay, config };
|
|
15
21
|
} catch (err) {
|
|
16
22
|
console.error('Load data failed', err);
|
|
17
|
-
return { commits: [], stats: {}, weekly: [] };
|
|
23
|
+
return { commits: [], stats: {}, weekly: [], monthly: [], latestByDay: [] };
|
|
18
24
|
}
|
|
19
25
|
}
|
|
20
26
|
|
|
@@ -157,10 +163,108 @@ function drawWeeklyTrend(weekly) {
|
|
|
157
163
|
return chart;
|
|
158
164
|
}
|
|
159
165
|
|
|
166
|
+
function drawMonthlyTrend(monthly) {
|
|
167
|
+
if (!Array.isArray(monthly) || monthly.length === 0) return null;
|
|
168
|
+
const labels = monthly.map(m => m.period);
|
|
169
|
+
const dataRate = monthly.map(m => +(m.outsideWorkRate * 100).toFixed(1));
|
|
170
|
+
const el = document.getElementById('monthlyTrendChart');
|
|
171
|
+
const chart = echarts.init(el);
|
|
172
|
+
chart.setOption({
|
|
173
|
+
tooltip: {},
|
|
174
|
+
xAxis: { type: 'category', data: labels },
|
|
175
|
+
yAxis: { type: 'value', min: 0, max: 100 },
|
|
176
|
+
series: [{ type: 'line', name: '加班占比(%)', data: dataRate }]
|
|
177
|
+
});
|
|
178
|
+
return chart;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function drawLatestHourDaily(latestByDay) {
|
|
182
|
+
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null;
|
|
183
|
+
const labels = latestByDay.map(d => d.date);
|
|
184
|
+
const raw = latestByDay.map(d => (typeof d.latestHourNormalized === 'number' ? d.latestHourNormalized : d.latestHour ?? null));
|
|
185
|
+
const data = raw.map(v => ({ value: v, itemStyle: { color: (v >= 24) ? '#d32f2f' : (v >= 21 ? '#fb8c00' : '#1976d2') } }));
|
|
186
|
+
const el = document.getElementById('latestHourDailyChart');
|
|
187
|
+
const chart = echarts.init(el);
|
|
188
|
+
chart.setOption({
|
|
189
|
+
tooltip: {
|
|
190
|
+
trigger: 'axis',
|
|
191
|
+
formatter: (params) => {
|
|
192
|
+
const p = Array.isArray(params) ? params[0] : params;
|
|
193
|
+
const v = p && p.value != null ? Number(p.value) : null;
|
|
194
|
+
const endH = (window.__overtimeEndHour || 18);
|
|
195
|
+
const sev = v != null ? Math.max(0, (v >= 24 ? v : v) - endH) : 0;
|
|
196
|
+
return `${p.axisValue}<br/>最晚小时: ${v != null ? v : '-'}<br/>超过下班: ${sev} 小时`;
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
xAxis: { type: 'category', data: labels },
|
|
200
|
+
yAxis: { type: 'value', min: 0, max: Math.max(26, Math.ceil(Math.max(...raw.filter(v => typeof v === 'number')) + 1)) },
|
|
201
|
+
series: [{
|
|
202
|
+
type: 'line',
|
|
203
|
+
name: '每日最晚提交小时',
|
|
204
|
+
data,
|
|
205
|
+
markLine: {
|
|
206
|
+
data: [
|
|
207
|
+
{ yAxis: 21 },
|
|
208
|
+
{ yAxis: 24 }
|
|
209
|
+
],
|
|
210
|
+
lineStyle: { color: '#fb8c00' }
|
|
211
|
+
}
|
|
212
|
+
}]
|
|
213
|
+
});
|
|
214
|
+
return chart;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function drawDailySeverity(latestByDay) {
|
|
218
|
+
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null;
|
|
219
|
+
const labels = latestByDay.map(d => d.date);
|
|
220
|
+
const endH = (window.__overtimeEndHour || 18);
|
|
221
|
+
const raw = latestByDay.map(d => (typeof d.latestHourNormalized === 'number' ? d.latestHourNormalized : d.latestHour ?? null));
|
|
222
|
+
const sev = raw.map(v => (v == null ? null : Math.max(0, Number(v) - endH)));
|
|
223
|
+
const el = document.getElementById('dailySeverityChart');
|
|
224
|
+
const chart = echarts.init(el);
|
|
225
|
+
chart.setOption({
|
|
226
|
+
tooltip: {},
|
|
227
|
+
xAxis: { type: 'category', data: labels },
|
|
228
|
+
yAxis: { type: 'value', min: 0 },
|
|
229
|
+
series: [{
|
|
230
|
+
type: 'line',
|
|
231
|
+
name: '超过下班小时数',
|
|
232
|
+
data: sev,
|
|
233
|
+
markLine: {
|
|
234
|
+
symbol: 'none',
|
|
235
|
+
data: [
|
|
236
|
+
{ yAxis: 1 },
|
|
237
|
+
{ yAxis: 2 }
|
|
238
|
+
],
|
|
239
|
+
lineStyle: { color: '#9e9e9e', type: 'dashed' }
|
|
240
|
+
}
|
|
241
|
+
}]
|
|
242
|
+
});
|
|
243
|
+
return chart;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderKpi(stats) {
|
|
247
|
+
const el = document.getElementById('kpiContent');
|
|
248
|
+
if (!el || !stats) return;
|
|
249
|
+
const latest = stats.latestCommit;
|
|
250
|
+
const latestHour = stats.latestCommitHour;
|
|
251
|
+
const latestOut = stats.latestOutsideCommit;
|
|
252
|
+
const latestOutHour = stats.latestOutsideCommitHour ?? (latestOut ? new Date(latestOut.date).getHours() : null);
|
|
253
|
+
const cutoff = window.__overnightCutoff ?? 6;
|
|
254
|
+
const html = [
|
|
255
|
+
`<div>最晚一次提交时间:${latest ? formatDate(latest.date) : '-'}${typeof latestHour === 'number' ? `(${String(latestHour).padStart(2,'0')}:00)` : ''}</div>`,
|
|
256
|
+
`<div>加班最晚一次提交时间:${latestOut ? formatDate(latestOut.date) : '-'}${typeof latestOutHour === 'number' ? `(${String(latestOutHour).padStart(2,'0')}:00)` : ''}</div>`,
|
|
257
|
+
`<div>次日归并窗口:凌晨 <b>${cutoff}</b> 点内归前一日</div>`
|
|
258
|
+
].join('');
|
|
259
|
+
el.innerHTML = html;
|
|
260
|
+
}
|
|
261
|
+
|
|
160
262
|
(async function main() {
|
|
161
|
-
const { commits, stats, weekly } = await loadData();
|
|
263
|
+
const { commits, stats, weekly, monthly, latestByDay, config } = await loadData();
|
|
162
264
|
commitsAll = commits;
|
|
163
265
|
filtered = commitsAll.slice();
|
|
266
|
+
window.__overtimeEndHour = stats && typeof stats.endHour === 'number' ? stats.endHour : (config.endHour ?? 18);
|
|
267
|
+
window.__overnightCutoff = typeof config.overnightCutoff === 'number' ? config.overnightCutoff : 6;
|
|
164
268
|
initTableControls();
|
|
165
269
|
updatePager();
|
|
166
270
|
renderCommitsTablePage();
|
|
@@ -168,4 +272,8 @@ function drawWeeklyTrend(weekly) {
|
|
|
168
272
|
drawOutsideVsInside(stats);
|
|
169
273
|
drawDailyTrend(commits);
|
|
170
274
|
drawWeeklyTrend(weekly);
|
|
275
|
+
drawMonthlyTrend(monthly);
|
|
276
|
+
drawLatestHourDaily(latestByDay);
|
|
277
|
+
drawDailySeverity(latestByDay);
|
|
278
|
+
renderKpi(stats);
|
|
171
279
|
})();
|
package/web/index.html
CHANGED
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
<h2>下班时间 vs 工作时间提交占比</h2>
|
|
18
18
|
<div id="outsideVsInsideChart" class="echart"></div>
|
|
19
19
|
</div>
|
|
20
|
-
<div class="chart-card"
|
|
20
|
+
<div class="chart-card" id="kpiCard">
|
|
21
|
+
<h2>关键指标</h2>
|
|
22
|
+
<div id="kpiContent"></div>
|
|
23
|
+
</div>
|
|
21
24
|
</section>
|
|
22
25
|
<section id="charts">
|
|
23
26
|
<div class="chart-card">
|
|
@@ -33,6 +36,18 @@
|
|
|
33
36
|
<h2>每周趋势(加班占比)</h2>
|
|
34
37
|
<div id="weeklyTrendChart" class="echart"></div>
|
|
35
38
|
</div>
|
|
39
|
+
<div class="chart-card">
|
|
40
|
+
<h2>每月趋势(加班占比)</h2>
|
|
41
|
+
<div id="monthlyTrendChart" class="echart"></div>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="chart-card">
|
|
44
|
+
<h2>每日最晚提交时间(小时)</h2>
|
|
45
|
+
<div id="latestHourDailyChart" class="echart"></div>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="chart-card">
|
|
48
|
+
<h2>每日超过下班的小时数</h2>
|
|
49
|
+
<div id="dailySeverityChart" class="echart"></div>
|
|
50
|
+
</div>
|
|
36
51
|
</section>
|
|
37
52
|
|
|
38
53
|
<section class="table-card">
|