wukong-gitlog-cli 1.0.16 → 1.0.17

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 CHANGED
@@ -2,6 +2,14 @@
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.17](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.16...v1.0.17) (2025-12-05)
6
+
7
+
8
+ ### Features
9
+
10
+ * 🎸 change line total ([bf01655](https://github.com/tomatobybike/wukong-gitlog-cli/commit/bf01655f3721b87d4bb36e74a7ce6dadf5e7ee94))
11
+ * 🎸 develop Changes totals ([fb64986](https://github.com/tomatobybike/wukong-gitlog-cli/commit/fb64986cfde215fc58ce7b1b66cbc8bdbf280627))
12
+
5
13
  ### [1.0.16](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.15...v1.0.16) (2025-12-03)
6
14
 
7
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wukong-gitlog-cli",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "description": "Advanced Git commit log exporter with Excel/JSON/TXT output, grouping, stats and CLI.",
5
5
  "keywords": [
6
6
  "git",
@@ -90,20 +90,19 @@
90
90
  ]
91
91
  },
92
92
  "dependencies": {
93
+ "boxen": "8.0.1",
94
+ "chalk": "5.6.2",
93
95
  "commander": "12.1.0",
94
- "dayjs": "1.11.19",
95
96
  "date-holidays": "2.1.1",
96
- "string-width": "5.1.2",
97
+ "dayjs": "1.11.19",
97
98
  "exceljs": "4.4.0",
98
- "zx": "7.2.4",
99
99
  "is-online": "12.0.2",
100
- "boxen": "8.0.1",
101
- "chalk": "5.6.2"
100
+ "ora": "9.0.0",
101
+ "string-width": "5.1.2",
102
+ "zx": "7.2.4"
102
103
  },
103
104
  "devDependencies": {
104
105
  "@trivago/prettier-plugin-sort-imports": "5.2.2",
105
- "husky": "^9.1.7",
106
- "lint-staged": "^16.2.7",
107
106
  "eslint": "8.57.1",
108
107
  "eslint-config-airbnb-base": "15.0.0",
109
108
  "eslint-config-prettier": "10.1.8",
@@ -111,6 +110,8 @@
111
110
  "eslint-plugin-import": "2.32.0",
112
111
  "eslint-plugin-prettier": "5.5.4",
113
112
  "eslint-plugin-simple-import-sort": "12.1.1",
113
+ "husky": "^9.1.7",
114
+ "lint-staged": "^16.2.7",
114
115
  "prettier": "3.6.2",
115
116
  "prettier-plugin-packagejson": "2.5.19",
116
117
  "sort-package-json": "3.4.0",
package/src/cli.mjs CHANGED
@@ -3,12 +3,18 @@ import { Command } from 'commander'
3
3
  import dayjs from 'dayjs'
4
4
  import isoWeek from 'dayjs/plugin/isoWeek.js'
5
5
  import fs from 'fs'
6
+ import ora from 'ora'
6
7
  import path from 'path'
7
8
  import { fileURLToPath } from 'url'
8
9
 
9
10
  import { CLI_NAME } from './constants/index.mjs'
10
- import { exportExcel, exportExcelPerPeriodSheets } from './excel.mjs'
11
+ import {
12
+ exportExcel,
13
+ exportExcelAuthorChangeStats,
14
+ exportExcelPerPeriodSheets
15
+ } from './excel.mjs'
11
16
  import { getGitLogs } from './git.mjs'
17
+ import { renderAuthorChangesJson } from './json.mjs'
12
18
  import {
13
19
  analyzeOvertime,
14
20
  renderOvertimeCsv,
@@ -16,7 +22,7 @@ import {
16
22
  renderOvertimeText
17
23
  } from './overtime.mjs'
18
24
  import { startServer } from './server.mjs'
19
- import { renderText } from './text.mjs'
25
+ import { renderChangedLinesText, renderText } from './text.mjs'
20
26
  import { checkUpdateWithPatch } from './utils/checkUpdate.mjs'
21
27
  import {
22
28
  groupRecords,
@@ -194,6 +200,8 @@ const main = async () => {
194
200
  return
195
201
  }
196
202
 
203
+ const spinner = ora('Loading...').start()
204
+
197
205
  let records = await getGitLogs(opts)
198
206
 
199
207
  // compute output directory root if user provided one or wants parent
@@ -342,6 +350,13 @@ const main = async () => {
342
350
  const dataCommitsFile = outputFilePath('data/commits.mjs', outDir)
343
351
  const commitsModule = `export default ${JSON.stringify(records, null, 2)};\n`
344
352
  writeTextFile(dataCommitsFile, commitsModule)
353
+
354
+ const dataCommitsChangedFile = outputFilePath('data/author-changes.mjs', outDir)
355
+ const jsonText = renderAuthorChangesJson(records)
356
+
357
+ const commitsChangedModule = `export default ${JSON.stringify(jsonText, null, 2)};\n`
358
+ writeTextFile(dataCommitsChangedFile, commitsChangedModule)
359
+
345
360
  const dataStatsFile = outputFilePath('data/overtime-stats.mjs', outDir)
346
361
  const statsModule = `export default ${JSON.stringify(stats, null, 2)};\n`
347
362
  writeTextFile(dataStatsFile, statsModule)
@@ -770,7 +785,10 @@ const main = async () => {
770
785
  const file = opts.out || 'commits.json'
771
786
  const filepath = outputFilePath(file, outDir)
772
787
  writeJSON(filepath, groups || records)
788
+ const jsonText = renderAuthorChangesJson(records)
789
+ writeJSON(outputFilePath('author-changes.json', outDir), jsonText)
773
790
  console.log(chalk.green(`JSON 已导出: ${filepath}`))
791
+ spinner.succeed('Done')
774
792
  return
775
793
  }
776
794
 
@@ -779,8 +797,13 @@ const main = async () => {
779
797
  const filepath = outputFilePath(file, outDir)
780
798
  const text = renderText(records, groups, { showGerrit: !!opts.gerrit })
781
799
  writeTextFile(filepath, text)
800
+ writeTextFile(
801
+ outputFilePath('author-changes.text', outDir),
802
+ renderChangedLinesText(records)
803
+ )
782
804
  console.log(text)
783
805
  console.log(chalk.green(`文本已导出: ${filepath}`))
806
+ spinner.succeed('Done')
784
807
  return
785
808
  }
786
809
 
@@ -794,11 +817,17 @@ const main = async () => {
794
817
  stats: opts.stats,
795
818
  gerrit: opts.gerrit
796
819
  })
820
+ await exportExcelAuthorChangeStats(
821
+ records,
822
+ outputFilePath('author_stats.xlsx', outDir)
823
+ )
797
824
  const text = renderText(records, groups)
798
825
  writeTextFile(txtPath, text)
799
826
  console.log(chalk.green(`Excel 已导出: ${excelPath}`))
800
827
  console.log(chalk.green(`文本已自动导出: ${txtPath}`))
828
+ spinner.succeed('Done')
801
829
  }
830
+
802
831
  await autoCheckUpdate()
803
832
  }
804
833
 
package/src/excel.mjs CHANGED
@@ -1,97 +1,167 @@
1
- import ExcelJS from 'exceljs';
2
- import dayjs from 'dayjs';
1
+ import dayjs from 'dayjs'
2
+ import ExcelJS from 'exceljs'
3
+ import { buildAuthorChangeStats } from './stats.mjs'
4
+
3
5
 
4
6
  export async function exportExcel(records, groups, options = {}) {
5
- const { file, stats, gerrit } = options;
7
+ const { file, stats, gerrit } = options
6
8
 
7
- const wb = new ExcelJS.Workbook();
8
- const ws = wb.addWorksheet('Commits');
9
+ const wb = new ExcelJS.Workbook()
10
+ const ws = wb.addWorksheet('Commits')
9
11
 
10
12
  const cols = [
11
13
  { header: 'Hash', key: 'hash', width: 12 },
12
14
  { header: 'Author', key: 'author', width: 20 },
13
15
  { header: 'Email', key: 'email', width: 30 },
14
16
  { header: 'Date', key: 'date', width: 20 },
15
- { header: 'Message', key: 'message', width: 80 }
16
- ];
17
+ { header: 'Message', key: 'message', width: 80 },
18
+ { header: 'Changed', key: 'changed', width: 12 } // ★ 新增 changed 列
19
+ ]
17
20
 
18
21
  if (gerrit) {
19
- cols.push({ header: 'Gerrit', key: 'gerrit', width: 50 });
22
+ cols.push({ header: 'Gerrit', key: 'gerrit', width: 50 })
20
23
  }
21
24
 
22
- ws.columns = cols;
25
+ ws.columns = cols
23
26
 
24
- (groups ? Object.values(groups).flat() : records).forEach(r =>
27
+ ;(groups ? Object.values(groups).flat() : records).forEach((r) =>
25
28
  ws.addRow(r)
26
- );
29
+ )
27
30
 
28
- ws.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: cols.length } };
31
+ ws.autoFilter = {
32
+ from: { row: 1, column: 1 },
33
+ to: { row: 1, column: cols.length }
34
+ }
29
35
 
30
36
  // --- stats sheet ---
31
37
  if (stats) {
32
- const statWs = wb.addWorksheet('Stats');
38
+ const statWs = wb.addWorksheet('Stats')
33
39
 
34
- const map = {};
40
+ const map = {}
35
41
 
36
- records.forEach(r => {
37
- const d = dayjs(r.date).format('YYYY-MM-DD');
38
- map[d] = (map[d] || 0) + 1;
39
- });
42
+ records.forEach((r) => {
43
+ const d = dayjs(r.date).format('YYYY-MM-DD')
44
+ map[d] = (map[d] || 0) + 1
45
+ })
40
46
 
41
47
  statWs.columns = [
42
48
  { header: 'Date', key: 'date', width: 15 },
43
49
  { header: 'Commits', key: 'count', width: 15 }
44
- ];
50
+ ]
45
51
 
46
52
  Object.entries(map).forEach(([d, cnt]) =>
47
53
  statWs.addRow({ date: d, count: cnt })
48
- );
54
+ )
49
55
  }
50
56
 
51
- await wb.xlsx.writeFile(file);
57
+ await wb.xlsx.writeFile(file)
52
58
  }
53
59
 
54
60
  export async function exportExcelPerPeriodSheets(groups, file, options = {}) {
55
- // groups: { periodKey: [records] }
56
- const { stats, gerrit } = options;
61
+ const { stats, gerrit } = options
57
62
 
58
- const wb = new ExcelJS.Workbook();
63
+ const wb = new ExcelJS.Workbook()
59
64
 
60
65
  const cols = [
61
66
  { header: 'Hash', key: 'hash', width: 12 },
62
67
  { header: 'Author', key: 'author', width: 20 },
63
68
  { header: 'Email', key: 'email', width: 30 },
64
69
  { header: 'Date', key: 'date', width: 20 },
65
- { header: 'Message', key: 'message', width: 80 }
66
- ];
67
- if (gerrit) cols.push({ header: 'Gerrit', key: 'gerrit', width: 50 });
70
+ { header: 'Message', key: 'message', width: 80 },
71
+ { header: 'Changed', key: 'changed', width: 12 } // ★ 新增 changed 列
72
+ ]
73
+
74
+ if (gerrit) cols.push({ header: 'Gerrit', key: 'gerrit', width: 50 })
75
+
76
+ const keys = Object.keys(groups).sort()
68
77
 
69
- const keys = Object.keys(groups).sort();
70
78
  keys.forEach((k) => {
71
- const ws = wb.addWorksheet(String(k).slice(0, 31)); // Excel sheet name limit 31
72
- ws.columns = cols;
73
- groups[k].forEach(r => ws.addRow(r));
74
- ws.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: cols.length } };
75
- });
76
-
77
- // summary sheet with counts per period
78
- const summary = wb.addWorksheet('Summary');
79
+ const ws = wb.addWorksheet(String(k).slice(0, 31))
80
+ ws.columns = cols
81
+ groups[k].forEach((r) => ws.addRow(r))
82
+
83
+ ws.autoFilter = {
84
+ from: { row: 1, column: 1 },
85
+ to: { row: 1, column: cols.length }
86
+ }
87
+ })
88
+
89
+ // summary sheet
90
+ const summary = wb.addWorksheet('Summary')
79
91
  summary.columns = [
80
92
  { header: 'Period', key: 'period', width: 20 },
81
93
  { header: 'Commits', key: 'count', width: 12 }
82
- ];
83
- keys.forEach(k => summary.addRow({ period: k, count: groups[k].length }));
94
+ ]
95
+ keys.forEach((k) => summary.addRow({ period: k, count: groups[k].length }))
84
96
 
85
- await wb.xlsx.writeFile(file);
97
+ await wb.xlsx.writeFile(file)
86
98
  }
87
99
 
88
- export async function exportExcelPerPeriodFiles(groups, dir, filePrefix, options = {}) {
89
- // groups: { periodKey: [records] }
90
- // dir: output directory path, filePrefix: base name without extension
91
- // For each key, call exportExcel with that key's records
92
- const keys = Object.keys(groups).sort();
100
+ export async function exportExcelPerPeriodFiles(
101
+ groups,
102
+ dir,
103
+ filePrefix,
104
+ options = {}
105
+ ) {
106
+ const keys = Object.keys(groups).sort()
93
107
  for (const k of keys) {
94
- const perFile = `${dir}/overtime_${filePrefix}_${k}.xlsx`;
95
- await exportExcel(groups[k], null, { file: perFile, stats: options.stats, gerrit: options.gerrit });
108
+ const perFile = `${dir}/overtime_${filePrefix}_${k}.xlsx`
109
+ await exportExcel(groups[k], null, {
110
+ file: perFile,
111
+ stats: options.stats,
112
+ gerrit: options.gerrit
113
+ })
96
114
  }
97
115
  }
116
+
117
+ /**
118
+ * 导出作者的日/周/月 changed 统计
119
+ * @param {*} records 原始 commits(包含 author/date/changed)
120
+ * @param {*} file 输出文件
121
+ */
122
+ export async function exportExcelAuthorChangeStats(records, file) {
123
+ const stats = buildAuthorChangeStats(records)
124
+
125
+ const wb = new ExcelJS.Workbook()
126
+
127
+ //-------------------------------------------
128
+ // 工具方法:创建一个统计 sheet
129
+ //-------------------------------------------
130
+ function addStatSheet(title, dataMap) {
131
+ const ws = wb.addWorksheet(title)
132
+
133
+ ws.columns = [
134
+ { header: 'Author', key: 'author', width: 20 },
135
+ { header: 'Period', key: 'period', width: 20 },
136
+ { header: 'Changed', key: 'changed', width: 12 }
137
+ ]
138
+
139
+ for (const [author, periodMap] of Object.entries(dataMap)) {
140
+ const periods = Object.keys(periodMap).sort()
141
+ for (const p of periods) {
142
+ ws.addRow({
143
+ author,
144
+ period: p,
145
+ changed: periodMap[p]
146
+ })
147
+ }
148
+ }
149
+
150
+ ws.autoFilter = {
151
+ from: { row: 1, column: 1 },
152
+ to: { row: 1, column: 3 }
153
+ }
154
+ }
155
+
156
+ //-------------------------------------------
157
+ // 创建 3 个 Sheet
158
+ //-------------------------------------------
159
+ addStatSheet('Author Daily', stats.daily)
160
+ addStatSheet('Author Weekly', stats.weekly)
161
+ addStatSheet('Author Monthly', stats.monthly)
162
+
163
+ //-------------------------------------------
164
+ // 保存文件
165
+ //-------------------------------------------
166
+ await wb.xlsx.writeFile(file)
167
+ }
package/src/git.mjs CHANGED
@@ -1,12 +1,9 @@
1
1
  import { $ } from 'zx'
2
2
 
3
-
4
3
  export async function getGitLogs(opts) {
5
4
  const { author, email, since, until, limit, merges } = opts
6
5
 
7
- // include subject and full body so we can extract Change-Id from commit message
8
6
  const pretty = '%H%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%B%x1e'
9
-
10
7
  const args = ['log', `--pretty=format:${pretty}`, '--date=iso']
11
8
 
12
9
  if (author) args.push(`--author=${author}`)
@@ -16,15 +13,13 @@ export async function getGitLogs(opts) {
16
13
  if (merges === false) args.push(`--no-merges`)
17
14
  if (limit) args.push(`-n`, `${limit}`)
18
15
 
19
- // 使用 spread 形式传参,ZX 才会正确处理
20
16
  const { stdout } = await $`git ${args}`.quiet()
21
17
 
22
- return stdout
18
+ const commits = stdout
23
19
  .split('\x1e')
24
20
  .filter(Boolean)
25
21
  .map((r) => {
26
22
  const f = r.split('\x1f').map((s) => (s || '').trim())
27
-
28
23
  const hash = f[0]
29
24
  const authorName = f[1]
30
25
  const emailAddr = f[2]
@@ -32,7 +27,6 @@ export async function getGitLogs(opts) {
32
27
  const subject = f[4]
33
28
  const body = f[5] || ''
34
29
 
35
- // extract Change-Id from commit body (line like "Change-Id: Iabc123...")
36
30
  const [, changeId] = body.match(/Change-Id:\s*(I[0-9a-fA-F]+)/) || []
37
31
 
38
32
  return {
@@ -45,4 +39,36 @@ export async function getGitLogs(opts) {
45
39
  changeId
46
40
  }
47
41
  })
42
+
43
+ // === 新增:为每个 commit 计算代码增量 ===
44
+ for (const c of commits) {
45
+ try {
46
+ const { stdout: diffOut } = await $`git show --numstat --format= ${c.hash}`.quiet()
47
+ // numstat 格式: "12 5 path/file.js"
48
+ const lines = diffOut
49
+ .split('\n')
50
+ .map((l) => l.trim())
51
+ .filter((l) => l && /^\d+\s+\d+/.test(l))
52
+
53
+ let added = 0
54
+ let deleted = 0
55
+
56
+ for (const line of lines) {
57
+ const [a, d] = line.split(/\s+/)
58
+ added += parseInt(a, 10) || 0
59
+ deleted += parseInt(d, 10) || 0
60
+ }
61
+
62
+ c.added = added
63
+ c.deleted = deleted
64
+ c.changed = added + deleted
65
+ } catch (err) {
66
+ // 避免阻塞,异常时改动量设置为0
67
+ c.added = 0
68
+ c.deleted = 0
69
+ c.changed = 0
70
+ }
71
+ }
72
+
73
+ return commits
48
74
  }
package/src/json.mjs ADDED
@@ -0,0 +1,5 @@
1
+ import { buildAuthorChangeStats } from './stats.mjs'
2
+
3
+ export function renderAuthorChangesJson(records) {
4
+ return buildAuthorChangeStats(records)
5
+ }
@@ -0,0 +1,51 @@
1
+ export function renderAuthorChangeStatsText(stats, opts = {}) {
2
+ const {
3
+ section = 'all', // 'daily' | 'weekly' | 'monthly' | 'all'
4
+ } = opts;
5
+
6
+ const pad = (s, n) =>
7
+ s.length >= n ? `${s.slice(0, n - 1)}…` : s + ' '.repeat(n - s.length);
8
+
9
+ const buildSection = (title, obj, unitLabel) => {
10
+ if (!obj || !Object.keys(obj).length) return '';
11
+
12
+ const lines = [];
13
+
14
+ const header =
15
+ `${pad('Author', 18)} | ${pad(unitLabel, 12)} | ${pad('Changed', 10)}`;
16
+ const line = '-'.repeat(header.length);
17
+
18
+ lines.push(`\n=== ${title} ===`);
19
+ lines.push(header);
20
+ lines.push(line);
21
+
22
+ for (const [author, periodMap] of Object.entries(obj)) {
23
+ const keys = Object.keys(periodMap).sort();
24
+ for (const key of keys) {
25
+ lines.push(
26
+ [
27
+ pad(author, 18),
28
+ pad(key, 12),
29
+ pad(String(periodMap[key]), 10),
30
+ ].join(' | ')
31
+ );
32
+ }
33
+ }
34
+
35
+ return lines.join('\n');
36
+ };
37
+
38
+ let output = '';
39
+
40
+ if (section === 'all' || section === 'daily') {
41
+ output += buildSection('Daily Changed', stats.daily, 'Date');
42
+ }
43
+ if (section === 'all' || section === 'weekly') {
44
+ output += buildSection('Weekly Changed', stats.weekly, 'Week');
45
+ }
46
+ if (section === 'all' || section === 'monthly') {
47
+ output += buildSection('Monthly Changed', stats.monthly, 'Month');
48
+ }
49
+
50
+ return `${output.trim() }\n`;
51
+ }
package/src/stats.mjs ADDED
@@ -0,0 +1,44 @@
1
+ import dayjs from 'dayjs'
2
+ import isoWeek from 'dayjs/plugin/isoWeek.js'
3
+
4
+ dayjs.extend(isoWeek)
5
+
6
+ /**
7
+ * 自动统计每个作者的日/周/月 changed
8
+ *
9
+ * records: [{
10
+ * author: 'Tom',
11
+ * date: '2025-01-01T12:00:00Z',
12
+ * changed: 123
13
+ * }]
14
+ */
15
+ export function buildAuthorChangeStats(records) {
16
+ const result = {
17
+ daily: {}, // { author: { 'YYYY-MM-DD': x } }
18
+ weekly: {}, // { author: { 'YYYY-WW': x } }
19
+ monthly: {} // { author: { 'YYYY-MM': x } }
20
+ }
21
+
22
+ for (const r of records) {
23
+ const author = r.author || 'Unknown'
24
+ const changed = Number(r.changed || 0)
25
+
26
+ const d = dayjs(r.date)
27
+
28
+ const dayKey = d.format('YYYY-MM-DD')
29
+ const weekKey = `${d.format('GGGG')}-W${d.isoWeek().toString().padStart(2, '0')}`
30
+ const monthKey = d.format('YYYY-MM')
31
+
32
+ if (!result.daily[author]) result.daily[author] = {}
33
+ if (!result.weekly[author]) result.weekly[author] = {}
34
+ if (!result.monthly[author]) result.monthly[author] = {}
35
+
36
+ result.daily[author][dayKey] = (result.daily[author][dayKey] || 0) + changed
37
+ result.weekly[author][weekKey] =
38
+ (result.weekly[author][weekKey] || 0) + changed
39
+ result.monthly[author][monthKey] =
40
+ (result.monthly[author][monthKey] || 0) + changed
41
+ }
42
+
43
+ return result
44
+ }
package/src/text.mjs CHANGED
@@ -1,56 +1,51 @@
1
+ import { renderAuthorChangeStatsText } from './stats-text.mjs'
2
+ import { buildAuthorChangeStats } from './stats.mjs'
3
+
1
4
  export function renderText(records, groups = null, opts = {}) {
2
- const { showGerrit = false } = opts;
5
+ const { showGerrit = false } = opts
3
6
  const pad = (s, n) =>
4
- s.length >= n ? `${s.slice(0, n - 1) }…` : s + ' '.repeat(n - s.length);
7
+ s.length >= n ? `${s.slice(0, n - 1)}…` : s + ' '.repeat(n - s.length)
8
+
9
+ const baseHeader = `${pad('Hash', 10)} | ${pad('Author', 18)} | ${pad(
10
+ 'Date',
11
+ 20
12
+ )} | ${pad('Message', 40)} | ${pad('Changed', 8)}` // ★ 新增 changed 列
5
13
 
6
- const baseHeader =
7
- `${pad('Hash', 10)
8
- } | ${
9
- pad('Author', 18)
10
- } | ${
11
- pad('Date', 20)
12
- } | ${
13
- pad('Message', 60)}`;
14
+ const gerritHeader = showGerrit ? ` | ${pad('Gerrit', 50)}` : ''
15
+ const header = baseHeader + gerritHeader
14
16
 
15
- const gerritHeader = showGerrit ? ` | ${ pad('Gerrit', 50)}` : '';
16
- const header = baseHeader + gerritHeader;
17
+ const line = '-'.repeat(header.length)
17
18
 
18
- const line = '-'.repeat(header.length);
19
+ const rows = []
19
20
 
20
- const rows = [];
21
+ const buildRow = (r) => {
22
+ return (
23
+ [
24
+ pad(r.hash.slice(0, 8), 10),
25
+ pad(r.author, 18),
26
+ pad(r.date.replace(/ .+/, ''), 20),
27
+ pad(r.message, 40),
28
+ pad(String(r.changed ?? 0), 8) // ★ 输出 changed 数量
29
+ ].join(' | ') + (showGerrit ? ` | ${pad(r.gerrit || '', 50)}` : '')
30
+ )
31
+ }
21
32
 
22
33
  if (groups) {
23
34
  for (const [g, list] of Object.entries(groups)) {
24
- rows.push(`\n=== ${g} ===\n`);
25
- list.forEach(r => {
26
- rows.push(
27
- (
28
- [
29
- pad(r.hash.slice(0, 8), 10),
30
- pad(r.author, 18),
31
- pad(r.date.replace(/ .+/, ''), 20),
32
- pad(r.message, 60)
33
- ].join(' | ') +
34
- (showGerrit ? ` | ${ pad(r.gerrit || '', 50)}` : '')
35
- )
36
- );
37
- });
35
+ rows.push(`\n=== ${g} ===\n`)
36
+ list.forEach((r) => rows.push(buildRow(r)))
38
37
  }
39
38
  } else {
40
- records.forEach(r => {
41
- rows.push(
42
- (
43
- [
44
- pad(r.hash.slice(0, 8), 10),
45
- pad(r.author, 18),
46
- pad(r.date.replace(/ .+/, ''), 20),
47
- pad(r.message, 60)
48
- ].join(' | ') +
49
- (showGerrit ? ` | ${ pad(r.gerrit || '', 50)}` : '')
50
- )
51
- );
52
- });
39
+ records.forEach((r) => rows.push(buildRow(r)))
53
40
  }
54
41
 
55
- return [header, line, ...rows].join('\n');
42
+ return [header, line, ...rows].join('\n')
43
+ }
44
+
45
+ export function renderChangedLinesText(records, opts = {}) {
46
+ const stats = buildAuthorChangeStats(records)
47
+
48
+ const result = renderAuthorChangeStatsText(stats, opts)
49
+
50
+ return result
56
51
  }
package/web/app.js CHANGED
@@ -9,14 +9,16 @@ async function loadData() {
9
9
  weeklyModule,
10
10
  monthlyModule,
11
11
  latestByDayModule,
12
- configModule
12
+ configModule,
13
+ authorChangesModule
13
14
  ] = await Promise.all([
14
15
  import('/data/commits.mjs'),
15
16
  import('/data/overtime-stats.mjs'),
16
17
  import('/data/overtime-weekly.mjs'),
17
18
  import('/data/overtime-monthly.mjs').catch(() => ({ default: [] })),
18
19
  import('/data/overtime-latest-by-day.mjs').catch(() => ({ default: [] })),
19
- import('/data/config.mjs').catch(() => ({ default: {} }))
20
+ import('/data/config.mjs').catch(() => ({ default: {} })),
21
+ import('/data/author-changes.mjs').catch(() => ({ default: {} }))
20
22
  ])
21
23
  const commits = commitsModule.default || []
22
24
  const stats = statsModule.default || {}
@@ -24,7 +26,8 @@ async function loadData() {
24
26
  const monthly = monthlyModule.default || []
25
27
  const latestByDay = latestByDayModule.default || []
26
28
  const config = configModule.default || {}
27
- return { commits, stats, weekly, monthly, latestByDay, config }
29
+ const authorChanges = authorChangesModule.default || {}
30
+ return { commits, stats, weekly, monthly, latestByDay, config, authorChanges }
28
31
  } catch (err) {
29
32
  console.error('Load data failed', err)
30
33
  return { commits: [], stats: {}, weekly: [], monthly: [], latestByDay: [] }
@@ -1226,50 +1229,6 @@ function groupCommitsByHour(commits) {
1226
1229
  return byHour
1227
1230
  }
1228
1231
 
1229
- async function main() {
1230
- const { commits, stats, weekly, monthly, latestByDay, config } =
1231
- await loadData()
1232
- commitsAll = commits
1233
- filtered = commitsAll.slice()
1234
- window.__overtimeEndHour =
1235
- stats && typeof stats.endHour === 'number'
1236
- ? stats.endHour
1237
- : (config.endHour ?? 18)
1238
- window.__overnightCutoff =
1239
- typeof config.overnightCutoff === 'number' ? config.overnightCutoff : 6
1240
- initTableControls()
1241
- updatePager()
1242
- renderCommitsTablePage()
1243
-
1244
- drawHourlyOvertime(stats, (hour, count) => {
1245
- // 使用举例
1246
- const hourCommitsDetail = groupCommitsByHour(commits)
1247
- // 将 commit 列表传给侧栏(若没有详情,则传空数组)
1248
- showSideBarForHour(hour, hourCommitsDetail[hour] || [])
1249
- })
1250
- drawOutsideVsInside(stats)
1251
-
1252
- // 按日提交趋势:点击某天打开抽屉,显示当日所有 commits
1253
- drawDailyTrend(commits, showDayDetailSidebar)
1254
-
1255
- // 周趋势:保持原有点击行为(显示该周详情)
1256
- drawWeeklyTrend(weekly, commits, showSideBarForWeek)
1257
-
1258
- // 月趋势(加班占比):点击某个月打开抽屉,显示该月所有 commits
1259
- drawMonthlyTrend(monthly, commits, showDayDetailSidebar)
1260
-
1261
- // 每日最晚提交时间(小时):点击某天打开抽屉,显示当日所有 commits
1262
- drawLatestHourDaily(latestByDay, commits, showDayDetailSidebar)
1263
-
1264
- // 每日超过下班的小时数:点击某天打开抽屉,显示当日所有 commits
1265
- drawDailySeverity(latestByDay, commits, showDayDetailSidebar)
1266
-
1267
- const daily = drawDailyTrendSeverity(commits, weekly, showDayDetailSidebar)
1268
-
1269
- console.log('最累的一天:', daily.analysis.mostTiredDay)
1270
- computeAndRenderLatestOvertime(latestByDay)
1271
- renderKpi(stats)
1272
- }
1273
1232
 
1274
1233
  // 基于 latestByDay + cutoff/endHour 统计「最晚加班的一天 / 一周 / 一月」
1275
1234
  function computeAndRenderLatestOvertime(latestByDay) {
@@ -1377,6 +1336,107 @@ function computeAndRenderLatestOvertime(latestByDay) {
1377
1336
  }
1378
1337
  }
1379
1338
 
1339
+ function buildDataset(stats, type) {
1340
+ const dataMap = stats[type]; // { author: { period: changed } }
1341
+
1342
+ const authors = Object.keys(dataMap);
1343
+ const allPeriods = Array.from(new Set(
1344
+ authors.flatMap(a => Object.keys(dataMap[a]))
1345
+ )).sort();
1346
+
1347
+ const series = authors.map(a => ({
1348
+ name: a,
1349
+ type: 'line',
1350
+ smooth: true,
1351
+ data: allPeriods.map(p => dataMap[a][p] || 0)
1352
+ }));
1353
+
1354
+ return { authors, allPeriods, series };
1355
+ }
1356
+
1357
+ const drawChangeTrends = (stats, type = 'daily') => {
1358
+ const el = document.getElementById("chartAuthorChanges");
1359
+ if (!el) return null;
1360
+ // FIXME: remove debug log before production
1361
+ console.log('❌', 'el', el);
1362
+ const chart = echarts.init(el);
1363
+
1364
+ function render(type) {
1365
+ const { authors, allPeriods, series } = buildDataset(stats, type);
1366
+
1367
+ chart.setOption({
1368
+ tooltip: { trigger: 'axis' },
1369
+ legend: { data: authors },
1370
+ xAxis: { type: 'category', data: allPeriods },
1371
+ yAxis: { type: 'value' },
1372
+ series
1373
+ });
1374
+ }
1375
+
1376
+ // 初次渲染:日
1377
+ render('daily');
1378
+
1379
+ // tabs 切换
1380
+ document.querySelectorAll('#tabs button').forEach(btn => {
1381
+ btn.onclick = () => {
1382
+ document.querySelectorAll('#tabs button').forEach(b => b.classList.remove('active'));
1383
+ btn.classList.add('active');
1384
+
1385
+ render(btn.dataset.type);
1386
+ };
1387
+ });
1388
+
1389
+ return chart;
1390
+ }
1391
+
1392
+ async function main() {
1393
+ const { commits, stats, weekly, monthly, latestByDay, config,authorChanges } =
1394
+ await loadData()
1395
+ commitsAll = commits
1396
+ filtered = commitsAll.slice()
1397
+ window.__overtimeEndHour =
1398
+ stats && typeof stats.endHour === 'number'
1399
+ ? stats.endHour
1400
+ : (config.endHour ?? 18)
1401
+ window.__overnightCutoff =
1402
+ typeof config.overnightCutoff === 'number' ? config.overnightCutoff : 6
1403
+ initTableControls()
1404
+ updatePager()
1405
+ renderCommitsTablePage()
1406
+
1407
+ drawHourlyOvertime(stats, (hour, count) => {
1408
+ // 使用举例
1409
+ const hourCommitsDetail = groupCommitsByHour(commits)
1410
+ // 将 commit 列表传给侧栏(若没有详情,则传空数组)
1411
+ showSideBarForHour(hour, hourCommitsDetail[hour] || [])
1412
+ })
1413
+ drawOutsideVsInside(stats)
1414
+
1415
+ // 按日提交趋势:点击某天打开抽屉,显示当日所有 commits
1416
+ drawDailyTrend(commits, showDayDetailSidebar)
1417
+
1418
+ // 周趋势:保持原有点击行为(显示该周详情)
1419
+ drawWeeklyTrend(weekly, commits, showSideBarForWeek)
1420
+
1421
+ // 月趋势(加班占比):点击某个月打开抽屉,显示该月所有 commits
1422
+ drawMonthlyTrend(monthly, commits, showDayDetailSidebar)
1423
+
1424
+ // 每日最晚提交时间(小时):点击某天打开抽屉,显示当日所有 commits
1425
+ drawLatestHourDaily(latestByDay, commits, showDayDetailSidebar)
1426
+
1427
+ // 每日超过下班的小时数:点击某天打开抽屉,显示当日所有 commits
1428
+ drawDailySeverity(latestByDay, commits, showDayDetailSidebar)
1429
+
1430
+ const daily = drawDailyTrendSeverity(commits, weekly, showDayDetailSidebar)
1431
+
1432
+ console.log('最累的一天:', daily.analysis.mostTiredDay)
1433
+
1434
+ drawChangeTrends(authorChanges, 'daily');
1435
+ computeAndRenderLatestOvertime(latestByDay)
1436
+ renderKpi(stats)
1437
+ }
1438
+
1439
+
1380
1440
  // 抽屉关闭交互(按钮 + 点击遮罩)
1381
1441
  document.getElementById('sidebarClose').onclick = () => {
1382
1442
  document.getElementById('dayDetailSidebar').classList.remove('show')
package/web/index.html CHANGED
@@ -61,6 +61,17 @@
61
61
  <div id="latestOvertimeMonth"></div>
62
62
  </section>
63
63
 
64
+ <div class="chart-card">
65
+ <h2>开发者 Changed 工作量趋势</h2>
66
+
67
+ <div id="tabs">
68
+ <button data-type="daily" class="active">按日</button>
69
+ <button data-type="weekly">按周</button>
70
+ <button data-type="monthly">按月</button>
71
+ </div>
72
+ <div id="chartAuthorChanges" class="echart"></div>
73
+ </div>
74
+
64
75
  <section class="table-card">
65
76
  <h2>提交清单</h2>
66
77
  <div id="tableControls">