wukong-gitlog-cli 1.0.38 → 1.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/.eslintrc +1 -0
  2. package/.prettierrc +2 -1
  3. package/CHANGELOG.md +103 -0
  4. package/README.md +93 -173
  5. package/README.zh-CN.md +85 -137
  6. package/doc//347/233/256/345/275/225/347/273/223/346/236/204.md +2871 -0
  7. package/package.json +33 -29
  8. package/rc/.wukonggitlogrc +53 -0
  9. package/scripts/compareHourlyCounts.mjs +42 -0
  10. package/scripts/compareLatest.mjs +106 -0
  11. package/src/app/analyzeAction.mjs +120 -0
  12. package/src/app/exportAction.mjs +215 -0
  13. package/src/app/exportActionProgress.mjs +37 -0
  14. package/src/app/helpers.mjs +292 -0
  15. package/src/app/initAction.mjs +110 -0
  16. package/src/app/initActionWithTemp.mjs +192 -0
  17. package/src/app/journalAction.mjs +117 -0
  18. package/src/app/overtimeAction.mjs +100 -0
  19. package/src/app/runProfileEnd.mjs +0 -0
  20. package/src/app/serveAction.mjs +73 -0
  21. package/src/app/versionAction.mjs +7 -0
  22. package/src/cli/defineOptions.mjs +209 -0
  23. package/src/cli/index.mjs +0 -0
  24. package/src/cli/parseOptions.mjs +126 -8
  25. package/src/constants/index.mjs +16 -2
  26. package/src/domain/author/analyze.mjs +6 -0
  27. package/src/domain/author/map.mjs +0 -0
  28. package/src/domain/export/exportAuthor.mjs +28 -0
  29. package/src/domain/export/exportAuthorChanges.mjs +27 -0
  30. package/src/domain/export/exportAuthorChangesJson.mjs +31 -0
  31. package/src/domain/export/exportByMonth.mjs +157 -0
  32. package/src/domain/export/exportByWeek.mjs +121 -0
  33. package/src/domain/export/exportCommits.mjs +26 -0
  34. package/src/domain/export/exportCommitsExcel.mjs +45 -0
  35. package/src/domain/export/exportCommitsJson.mjs +31 -0
  36. package/src/domain/export/index.mjs +91 -0
  37. package/src/domain/git/ensureGitAvailable.mjs +66 -0
  38. package/src/domain/git/ensureGitRepo.mjs +41 -0
  39. package/src/domain/git/getGitFeatures.mjs +59 -0
  40. package/src/domain/git/getGitLogs.mjs +326 -0
  41. package/src/domain/git/getGitUser.mjs +44 -0
  42. package/src/domain/git/getRepoRoot.mjs +32 -0
  43. package/src/domain/git/gitCapability.mjs +119 -0
  44. package/src/domain/git/index.mjs +96 -0
  45. package/src/domain/git/resolveGerrit.mjs +102 -0
  46. package/src/domain/overtime/analyze.mjs +48 -0
  47. package/src/domain/overtime/index.mjs +3 -0
  48. package/src/domain/overtime/perPeriod.mjs +15 -0
  49. package/src/domain/overtime/render.mjs +15 -0
  50. package/src/i18n/index.mjs +38 -0
  51. package/src/i18n/resources.mjs +252 -0
  52. package/src/index.mjs +132 -649
  53. package/src/infra/cache.mjs +0 -0
  54. package/src/infra/configStore.mjs +128 -0
  55. package/src/infra/fs.mjs +0 -0
  56. package/src/infra/path.mjs +0 -0
  57. package/src/output/csv/overtime.mjs +12 -0
  58. package/src/output/csv.mjs +0 -0
  59. package/src/output/data/readData.mjs +54 -0
  60. package/src/output/data/writeData.mjs +145 -0
  61. package/src/output/excel/commits.mjs +9 -0
  62. package/src/output/excel/outputExcelDayReport.mjs +92 -0
  63. package/src/output/excel/perPeriod.mjs +24 -0
  64. package/src/{excel.mjs → output/excel.mjs} +3 -2
  65. package/src/output/index.mjs +79 -0
  66. package/src/output/json/overtime.mjs +9 -0
  67. package/src/output/tab/overtime.mjs +12 -0
  68. package/src/output/tab.mjs +0 -0
  69. package/src/output/text/commits.mjs +9 -0
  70. package/src/output/text/index.mjs +3 -0
  71. package/src/output/text/outputTxtDayReport.mjs +74 -0
  72. package/src/output/text/overtime.mjs +18 -0
  73. package/src/output/utils/getEsmJs.mjs +10 -0
  74. package/src/output/utils/index.mjs +14 -0
  75. package/src/output/utils/outputPath.mjs +19 -0
  76. package/src/output/utils/writeFile.mjs +10 -0
  77. package/src/serve/index.mjs +0 -0
  78. package/src/{server.mjs → serve/startServer.mjs} +21 -3
  79. package/src/serve/writeData.mjs +0 -0
  80. package/src/utils/authorNormalizer.mjs +28 -2
  81. package/src/utils/buildAuthorChangeStats.mjs +44 -0
  82. package/src/utils/deepMerge.mjs +13 -0
  83. package/src/utils/getPackage.mjs +11 -0
  84. package/src/utils/getProfileDirFile.mjs +12 -0
  85. package/src/utils/{file.mjs → groupRecords.mjs} +8 -9
  86. package/src/utils/index.mjs +5 -2
  87. package/src/utils/logger.mjs +28 -17
  88. package/src/utils/profiler.mjs +0 -101
  89. package/src/utils/resolve.mjs +11 -0
  90. package/src/utils/showVersionInfo.mjs +6 -2
  91. package/src/utils/time.mjs +0 -0
  92. package/src/utils/wait.mjs +2 -0
  93. package/web/app.js +3233 -260
  94. package/web/index.html +175 -22
  95. package/web/revoke/alpha1/app.js +4324 -0
  96. package/web/revoke/alpha1/index.html +266 -0
  97. package/web/revoke/app.before.js +3139 -0
  98. package/web/revoke/index-before.html +181 -0
  99. package/web/static/style.css +155 -9
  100. package/src/git.mjs +0 -256
  101. package/src/handlers/handleServe.mjs +0 -203
  102. package/src/lib/configStore.mjs +0 -11
  103. package/src/lib/memoize.mjs +0 -14
  104. package/src/utils/analyzeOvertimeCached.mjs +0 -7
  105. package/src/utils/checkUpdate.mjs +0 -130
  106. package/src/utils/exitWithTime.mjs +0 -17
  107. package/src/utils/handleSuccess.mjs +0 -9
  108. package/src/utils/logDev.mjs +0 -19
  109. package/src/utils/output.mjs +0 -26
  110. package/src/utils/profiler/diff.mjs +0 -26
  111. package/src/utils/profiler/format.mjs +0 -11
  112. package/src/utils/profiler/index.mjs +0 -144
  113. package/src/utils/profiler/trace.mjs +0 -26
  114. package/src/utils/time/scopeTimer.mjs +0 -37
  115. package/src/utils/time/timer.mjs +0 -33
  116. package/src/utils/time/withTimer.mjs +0 -11
  117. package/src/utils/timer.mjs +0 -35
  118. /package/src/{overtime → domain/overtime}/createOvertimeStats.mjs +0 -0
  119. /package/src/{overtime → domain/overtime}/overtime.mjs +0 -0
  120. /package/src/{json.mjs → output/json.mjs} +0 -0
  121. /package/src/{renderAuthorMapText.mjs → output/renderAuthorMapText.mjs} +0 -0
  122. /package/src/{stats-text.mjs → output/stats-text.mjs} +0 -0
  123. /package/src/{stats.mjs → output/stats.mjs} +0 -0
  124. /package/src/{text.mjs → output/text.mjs} +0 -0
package/web/app.js CHANGED
@@ -1,8 +1,60 @@
1
1
  /* eslint-disable import/no-absolute-path */
2
2
  /* eslint-disable no-use-before-define */
3
+ /* eslint-disable no-restricted-globals */
3
4
  /* global echarts */
5
+
6
+ // 1. 定义一个存储所有实例的数组
7
+ const chartInstances = []
4
8
  const formatDate = (d) => new Date(d).toLocaleString()
5
9
 
10
+ // 综合判断函数,考虑多种情况
11
+ function isEmptyObject(obj) {
12
+ // 1. 检查是否为对象
13
+ if (obj === null || typeof obj !== 'object') {
14
+ return false
15
+ }
16
+
17
+ // 2. 检查是否是空对象
18
+ return Object.keys(obj).length === 0
19
+ }
20
+
21
+ // 根据对象内容隐藏对应图表卡片
22
+ function hideElementByObj({ el, objectName }) {
23
+ const isEmpty = isEmptyObject(objectName)
24
+ if (isEmpty) {
25
+ const chartCard = el?.closest('.chart-card')
26
+ chartCard.style.display = 'none'
27
+ return true
28
+ }
29
+ return isEmpty
30
+ }
31
+
32
+ function hideElementByEl({ el }) {
33
+ if (!el) return true
34
+ const chartCard = el.closest('.chart-card')
35
+ if (chartCard) {
36
+ chartCard.style.display = 'none'
37
+ return true
38
+ }
39
+ return false
40
+ }
41
+
42
+ function filterByDate(commits) {
43
+ const start = document.getElementById('startDate')?.value
44
+ const end = document.getElementById('endDate')?.value
45
+
46
+ if (!start && !end) return commits
47
+
48
+ const startTime = start ? new Date(`${start}T00:00:00`).getTime() : -Infinity
49
+
50
+ const endTime = end ? new Date(`${end}T23:59:59`).getTime() : Infinity
51
+
52
+ return commits.filter((c) => {
53
+ const t = new Date(c.date).getTime()
54
+ return t >= startTime && t <= endTime
55
+ })
56
+ }
57
+
6
58
  // ISO 周 key:YYYY-Www
7
59
  function getIsoWeekKey(dStr) {
8
60
  const d = new Date(dStr)
@@ -23,6 +75,38 @@ function formatDateYMD(d) {
23
75
  return `${yyyy}-${mm}-${dd}`
24
76
  }
25
77
 
78
+ // 将 CLI 传入或 RC 中的 author 筛选信息格式化为可读字符串
79
+ function formatAuthorFilter(a) {
80
+ if (!a) return null
81
+
82
+ function toList(v) {
83
+ if (!v) return null
84
+ if (Array.isArray(v)) return v.map((s) => String(s).trim()).filter(Boolean)
85
+ return String(v)
86
+ .split(',')
87
+ .map((s) => s.trim())
88
+ .filter(Boolean)
89
+ }
90
+
91
+ if (typeof a === 'string') {
92
+ const list = toList(a)
93
+ return list && list.length ? list.join(', ') : String(a)
94
+ }
95
+
96
+ if (Array.isArray(a)) return a.join(', ')
97
+
98
+ if (typeof a === 'object') {
99
+ const include = toList(a.include)
100
+ const exclude = toList(a.exclude)
101
+ const parts = []
102
+ if (include && include.length) parts.push(`包含:${include.join(', ')}`)
103
+ if (exclude && exclude.length) parts.push(`排除:${exclude.join(', ')}`)
104
+ return parts.length ? parts.join(';') : null
105
+ }
106
+
107
+ return String(a)
108
+ }
109
+
26
110
  function getISOWeekRange(isoYear, isoWeek) {
27
111
  // 找到 ISO 年的第一个周一
28
112
  // ISO 年的第 1 周包含 1 月 4 日
@@ -45,44 +129,27 @@ function getISOWeekRange(isoYear, isoWeek) {
45
129
  }
46
130
 
47
131
  async function loadData() {
48
- try {
49
- const [
50
- commitsModule,
51
- statsModule,
52
- weeklyModule,
53
- monthlyModule,
54
- latestByDayModule,
55
- configModule,
56
- authorChangesModule
57
- ] = await Promise.all([
58
- import('/data/commits.mjs'),
59
- import('/data/overtime-stats.mjs'),
60
- import('/data/overtime-weekly.mjs'),
61
- import('/data/overtime-monthly.mjs').catch(() => ({ default: [] })),
62
- import('/data/overtime-latest-by-day.mjs').catch(() => ({ default: [] })),
63
- import('/data/config.mjs').catch(() => ({ default: {} })),
64
- import('/data/author-changes.mjs').catch(() => ({ default: {} }))
65
- ])
66
- const commits = commitsModule.default || []
67
- const stats = statsModule.default || {}
68
- const weekly = weeklyModule.default || []
69
- const monthly = monthlyModule.default || []
70
- const latestByDay = latestByDayModule.default || []
71
- const config = configModule.default || {}
72
- const authorChanges = authorChangesModule.default || {}
73
- return {
74
- commits,
75
- stats,
76
- weekly,
77
- monthly,
78
- latestByDay,
79
- config,
80
- authorChanges
132
+ // 定义加载函数,包装 import 以便添加错误处理
133
+ const safeImport = async (path, defaultValue) => {
134
+ try {
135
+ const module = await import(path)
136
+ return module.default || defaultValue
137
+ } catch (e) {
138
+ console.warn(`文件加载失败: ${path}`, e)
139
+ return defaultValue
81
140
  }
82
- } catch (err) {
83
- console.error('Load data failed', err)
84
- return { commits: [], stats: {}, weekly: [], monthly: [], latestByDay: [] }
85
141
  }
142
+
143
+ // 并行加载基础数据(只加载快速的 analyze 生成的文件)
144
+ // 移除 overtime 文件加载,改为前端实时计算
145
+ const [commits, config, authorChanges, options] = await Promise.all([
146
+ safeImport('/data/commits.mjs', []),
147
+ safeImport('/data/config.mjs', {}),
148
+ safeImport('/data/author.changes.mjs', {}),
149
+ safeImport('/data/options.mjs', {})
150
+ ])
151
+
152
+ return { commits, config, authorChanges, options }
86
153
  }
87
154
 
88
155
  let commitsAll = []
@@ -95,12 +162,17 @@ function renderCommitsTablePage() {
95
162
  tbody.innerHTML = ''
96
163
  const start = (page - 1) * pageSize
97
164
  const end = start + pageSize
165
+ if(!window.__config.git.numstat) {
166
+ document.getElementById('thChanged').style.display = 'none'
167
+ }
98
168
  filtered.slice(start, end).forEach((c) => {
99
169
  const tr = document.createElement('tr')
100
- tr.innerHTML = `<td>${c.hash.slice(0, 8)}</td><td>${c.author}</td><td>${c.email}</td><td>${formatDate(c.date)}</td><td>${c.message}</td><td>${c.changed}</td>`
170
+ const changedTd = window.__config.git.numstat ? `<td>${c.changed}</td>` : ''
171
+ tr.innerHTML = `<td class="hash">${c.hash.slice(0, 8)}</td><td>${c.author}</td><td class="email">${c.email}</td><td><div class="date">${formatDate(c.date)}</div></td><td>${c.message}</td><td class="cherryPick">${c.isCherryPick}</td>${changedTd}`
101
172
  tbody.appendChild(tr)
102
173
  })
103
- document.getElementById('commitsTotal').textContent = `共${filtered.length}条记录`
174
+ document.getElementById('commitsTotal').textContent =
175
+ `共${filtered.length}条记录`
104
176
  }
105
177
 
106
178
  function updatePager() {
@@ -115,16 +187,25 @@ function updatePager() {
115
187
  function applySearch() {
116
188
  const q = document.getElementById('searchInput').value.trim().toLowerCase()
117
189
 
190
+ // ① 先做日期过滤
191
+ const base = filterByDate(commitsAll)
192
+
118
193
  if (!q) {
119
- filtered = commitsAll.slice()
194
+ filtered = base.slice()
120
195
  } else {
121
- filtered = commitsAll.filter((c) => {
196
+ filtered = base.filter((c) => {
122
197
  const h = c.hash.toLowerCase()
123
198
  const a = String(c.author || '').toLowerCase()
124
199
  const e = String(c.email || '').toLowerCase()
125
200
  const m = String(c.message || '').toLowerCase()
126
201
  const d = formatDate(c.date).toLowerCase()
127
- return h.includes(q) || a.includes(q)|| e.includes(q) || m.includes(q) || d.includes(q)
202
+ return (
203
+ h.includes(q) ||
204
+ a.includes(q) ||
205
+ e.includes(q) ||
206
+ m.includes(q) ||
207
+ d.includes(q)
208
+ )
128
209
  })
129
210
  }
130
211
  page = 1
@@ -134,6 +215,13 @@ function applySearch() {
134
215
 
135
216
  function initTableControls() {
136
217
  document.getElementById('searchInput').addEventListener('input', applySearch)
218
+ document.getElementById('startDate')?.addEventListener('change', applySearch)
219
+ document.getElementById('endDate')?.addEventListener('change', applySearch)
220
+ document.getElementById('clearDate')?.addEventListener('click', () => {
221
+ document.getElementById('startDate').value = ''
222
+ document.getElementById('endDate').value = ''
223
+ applySearch()
224
+ })
137
225
  document.getElementById('pageSize').addEventListener('change', (e) => {
138
226
  pageSize = parseInt(e.target.value, 10) || 10
139
227
  page = 1
@@ -159,28 +247,61 @@ function initTableControls() {
159
247
 
160
248
  function drawHourlyOvertime(stats, onHourClick) {
161
249
  const el = document.getElementById('hourlyOvertimeChart')
250
+
251
+ const isEmpty = hideElementByObj({ el, objectName: stats })
252
+ if (isEmpty) {
253
+ return false
254
+ }
162
255
  const chart = echarts.init(el)
256
+ chartInstances.push(chart)
163
257
 
164
- const commits = stats.hourlyOvertimeCommits || []
165
- const percent = stats.hourlyOvertimePercent || []
258
+ // 显示所有提交数(不仅仅是加班)
259
+ const allCommits = Array(24).fill(0)
166
260
  const labels = Array.from({ length: 24 }, (_, i) =>
167
261
  String(i).padStart(2, '0')
168
262
  )
169
263
 
170
- // 颜色逻辑(与 daily severity 风格一致)
264
+ // 从原始 commits 数据重新计算每小时的所有提交数
265
+ // 如果没有则使用后备逻辑
266
+ if (window.__allCommitsData && Array.isArray(window.__allCommitsData)) {
267
+ window.__allCommitsData.forEach((c) => {
268
+ const d = new Date(c.date)
269
+ if (!isNaN(d.getTime())) {
270
+ const h = d.getHours()
271
+ allCommits[h]++
272
+ }
273
+ })
274
+ }
275
+
276
+ // 颜色逻辑:根据时间段着色
171
277
  function getColor(h) {
172
- if (h >= 21) return '#d32f2f' // 深夜加班
173
- if (h >= 19) return '#fb8c00' // 夜间加班 橙
174
- if (h >= stats.lunchStart && h < stats.lunchEnd) return '#888888' // 午休灰
175
- if (h >= stats.startHour && h < stats.endHour) return '#1976d2' // 工作时段 蓝
176
- return '#b71c1c' // 凌晨 红
278
+ // 深夜(0-9 点)红色
279
+ if (h < stats.startHour) return '#b71c1c'
280
+ // 上班开始到午休开始:蓝色
281
+ if (h >= stats.startHour && h < stats.lunchStart) return '#1976d2'
282
+ // 午休时间:灰色
283
+ if (h >= stats.lunchStart && h < stats.lunchEnd) return '#888888'
284
+ // 午休结束到下班:蓝色
285
+ if (h >= stats.lunchEnd && h < stats.endHour) return '#1976d2'
286
+ // 下班后到晚上 19:00:橙色
287
+ if (h >= stats.endHour && h < 19) return '#fb8c00'
288
+ // 晚上 19:00 到深夜 21:00:深橙
289
+ if (h >= 19 && h < 21) return '#fb8c00'
290
+ // 深夜 21:00 后:红色
291
+ return '#d32f2f'
177
292
  }
178
293
 
179
- const data = commits.map((v, h) => ({
294
+ const data = allCommits.map((v, h) => ({
180
295
  value: v,
181
296
  itemStyle: { color: getColor(h) }
182
297
  }))
183
298
 
299
+ // 计算百分比
300
+ const total = allCommits.reduce((sum, v) => sum + v, 0)
301
+ const percentData = allCommits.map((v) =>
302
+ total > 0 ? ((v / total) * 100).toFixed(1) : 0
303
+ )
304
+
184
305
  chart.setOption({
185
306
  tooltip: {
186
307
  trigger: 'axis',
@@ -188,11 +309,31 @@ function drawHourlyOvertime(stats, onHourClick) {
188
309
  const p = params[0]
189
310
  const h = parseInt(p.axisValue, 10)
190
311
  const count = p.value
191
- const rate = (percent[h] * 100).toFixed(1)
312
+ const percent = percentData[h]
313
+
314
+ // 判断时间段
315
+ let period = ''
316
+ if (h < stats.startHour) {
317
+ period = '深夜(需要休息)'
318
+ } else if (h >= stats.startHour && h < stats.lunchStart) {
319
+ period = '早上工作时间'
320
+ } else if (h >= stats.lunchStart && h < stats.lunchEnd) {
321
+ period = '午休时间'
322
+ } else if (h >= stats.lunchEnd && h < stats.endHour) {
323
+ period = '下午工作时间'
324
+ } else if (h >= stats.endHour && h < 19) {
325
+ period = '下班后(轻度加班)'
326
+ } else if (h >= 19 && h < 21) {
327
+ period = '晚间(中度加班)'
328
+ } else {
329
+ period = '深夜(严重加班)'
330
+ }
331
+
192
332
  return `
193
333
  🕒 <b>${h}:00</b><br/>
194
334
  提交次数:<b>${count}</b><br/>
195
- 占全天比例:<b>${rate}%</b>
335
+ 占全天比例:<b>${percent}%</b><br/>
336
+ 时段:${period}
196
337
  `
197
338
  }
198
339
  },
@@ -214,7 +355,7 @@ function drawHourlyOvertime(stats, onHourClick) {
214
355
  series: [
215
356
  {
216
357
  type: 'bar',
217
- name: 'Overtime commits',
358
+ name: '每小时提交',
218
359
  data,
219
360
  barWidth: 18,
220
361
 
@@ -227,7 +368,7 @@ function drawHourlyOvertime(stats, onHourClick) {
227
368
  name: '最晚提交',
228
369
  coord: [
229
370
  String(stats.latestCommitHour).padStart(2, '0'),
230
- commits[stats.latestCommitHour]
371
+ allCommits[stats.latestCommitHour] || 0
231
372
  ]
232
373
  }
233
374
  ]
@@ -274,14 +415,21 @@ function drawHourlyOvertime(stats, onHourClick) {
274
415
  }
275
416
  document.getElementById('dayDetailSidebar').classList.remove('show')
276
417
  if (Number.isNaN(hour)) return
277
- onHourClick(hour, commits[hour])
418
+
419
+ // 获取该小时的所有提交
420
+ const hourCommits = (window.__allCommitsData || []).filter((c) => {
421
+ const d = new Date(c.date)
422
+ return !isNaN(d.getTime()) && d.getHours() === hour
423
+ })
424
+
425
+ onHourClick(hour, hourCommits)
278
426
  })
279
427
  }
280
428
 
281
429
  return chart
282
430
  }
283
431
 
284
- // showSideBarForHour 实现
432
+ // 每小时加班分布
285
433
  function showSideBarForHour({ hour, commitsOrCount, titleDrawer }) {
286
434
  // 支持传入 number(仅次数)或 array(详细 commit 列表)
287
435
  // 统一复用通用详情侧栏 DOM
@@ -311,14 +459,14 @@ function showSideBarForHour({ hour, commitsOrCount, titleDrawer }) {
311
459
  // commits 列表:展示作者/时间/消息(最多前 50 条,避免性能问题)
312
460
  const commits = commitsOrCount.slice(0, 50)
313
461
  contentEl.innerHTML = `<div class="sidebar-list">${commits
314
- .map((c) => {
462
+ .map((c, index) => {
315
463
  const author = c.author ?? c.name ?? 'unknown'
316
464
  const time = c.date ?? c.time ?? ''
317
465
  const msg = (c.message ?? c.msg ?? c.body ?? '').replace(/\n/g, ' ')
318
466
  return `
319
467
  <div class="sidebar-item">
320
468
  <div class="sidebar-item-header">
321
- <span class="author">👤 ${escapeHtml(author)}</span>
469
+ <span class="author">${index + 1}. 👤 ${escapeHtml(author)}</span>
322
470
  <span class="time">🕒 ${escapeHtml(time)}</span>
323
471
  </div>
324
472
  <div class="sidebar-item-message">${escapeHtml(msg)}</div>
@@ -350,30 +498,121 @@ function escapeHtml(str = '') {
350
498
  .replaceAll("'", '&#39;')
351
499
  }
352
500
 
353
- function drawOutsideVsInside(stats) {
354
- const el = document.getElementById('outsideVsInsideChart')
355
- // eslint-disable-next-line no-undef
356
- const chart = echarts.init(el)
357
- const outside = stats.outsideWorkCount || 0
358
- const total = stats.total || 0
359
- const inside = Math.max(0, total - outside)
501
+ /**
502
+ * 通用环形饼图(中心显示总数)
503
+ *
504
+ * @param {Object} options
505
+ * @param {string|HTMLElement} options.el DOM id
506
+ * @param {Array} options.data [{ name, value }]
507
+ * @param {string} [options.title] 标题
508
+ * @param {string} [options.unit='次'] 单位
509
+ * @param {Array} [options.colors] 自定义颜色
510
+ * @param {string} [options.totalLabel='总计'] 中心文案
511
+ */
512
+ export function drawPieWithTotal({
513
+ el,
514
+ data = [],
515
+ title = '',
516
+ unit = '次',
517
+ colors = [],
518
+ totalLabel = '总计'
519
+ }) {
520
+ const dom = typeof el === 'string' ? document.getElementById(el) : el
521
+ const chart = echarts.init(dom)
522
+ chartInstances.push(chart)
523
+
524
+ let total = data.reduce((sum, item) => sum + (item.value || 0), 0)
525
+ total = Math.round(total)
526
+ // FIXME: remove debug log before production
527
+ console.log('❌', 'total', total)
528
+ const safeData = total === 0 ? [{ name: '暂无数据', value: 1 }] : data
529
+
360
530
  chart.setOption({
361
- tooltip: {},
531
+ color: colors.length ? colors : undefined,
532
+
533
+ title: title
534
+ ? {
535
+ text: title,
536
+ left: 'center',
537
+ top: 10
538
+ }
539
+ : undefined,
540
+
541
+ tooltip: {
542
+ trigger: 'item',
543
+ formatter: (params) => {
544
+ if (total === 0) return '暂无数据'
545
+ return `
546
+ ${params.name}<br/>
547
+ 数量:${params.value} ${unit}<br/>
548
+ 占比:${params.percent}%
549
+ `
550
+ }
551
+ },
552
+ legend: {
553
+ bottom: 0,
554
+ formatter: (name) => `${name}`
555
+ },
556
+ graphic:
557
+ total === 0
558
+ ? []
559
+ : [
560
+ {
561
+ type: 'text',
562
+ left: 'center',
563
+ top: '45%',
564
+ style: {
565
+ text: `${totalLabel}\n${total} ${unit}`,
566
+ textAlign: 'center',
567
+ fill: '#333',
568
+ fontSize: 14,
569
+ fontWeight: 600
570
+ }
571
+ }
572
+ ],
573
+
362
574
  series: [
363
575
  {
364
576
  type: 'pie',
365
- radius: '55%',
366
- data: [
367
- { value: inside, name: '工作时间内' },
368
- { value: outside, name: '下班时间' }
369
- ]
577
+ radius: ['40%', '60%'],
578
+ avoidLabelOverlap: false,
579
+ label: {
580
+ show: total !== 0,
581
+ formatter: `{b}\n{c} ${unit}`
582
+ },
583
+ emphasis: {
584
+ scale: true,
585
+ scaleSize: 8
586
+ },
587
+ data: safeData
370
588
  }
371
589
  ]
372
590
  })
591
+
373
592
  return chart
374
593
  }
375
594
 
595
+ function drawOutsideVsInside(stats) {
596
+ const outside = stats.outsideWorkCount || 0
597
+ const total = stats.total || 0
598
+ const inside = Math.max(0, total - outside)
599
+
600
+ return drawPieWithTotal({
601
+ el: 'outsideVsInsideChart',
602
+ title: '提交时间分布',
603
+ unit: '次',
604
+ totalLabel: '总提交',
605
+ data: [
606
+ { name: '工作时间内', value: inside },
607
+ { name: '下班时间', value: outside }
608
+ ],
609
+ colors: ['#5470C6', '#EE6666']
610
+ })
611
+ }
612
+
376
613
  function drawDailyTrend(commits, onDayClick) {
614
+ const el = document.getElementById('dailyTrendChart')
615
+
377
616
  if (!Array.isArray(commits) || commits.length === 0) return null
378
617
 
379
618
  // 聚合每日提交数量
@@ -386,11 +625,11 @@ function drawDailyTrend(commits, onDayClick) {
386
625
  const labels = Array.from(map.keys()).sort()
387
626
  const data = labels.map((l) => map.get(l))
388
627
 
389
- const el = document.getElementById('dailyTrendChart')
390
628
  const titleDrawer = el.getAttribute('data-title') || ''
391
629
 
392
630
  // eslint-disable-next-line no-undef
393
631
  const chart = echarts.init(el)
632
+ chartInstances.push(chart)
394
633
 
395
634
  chart.setOption({
396
635
  tooltip: {
@@ -517,14 +756,14 @@ function showSideBarForWeek({ period, weeklyItem, commits = [], titleDrawer }) {
517
756
  html += `<div style="padding:10px;color:#777;">该周无提交记录</div>`
518
757
  } else {
519
758
  html += `<div class="sidebar-list">${commits
520
- .map((c) => {
759
+ .map((c, index) => {
521
760
  const author = escapeHtml(c.author || 'unknown')
522
761
  const time = escapeHtml(c.date || '')
523
762
  const msg = escapeHtml((c.message || '').replace(/\n/g, ' '))
524
763
  return `
525
764
  <div class="sidebar-item">
526
765
  <div class="sidebar-item-header">
527
- <span class="author">👤 ${author}</span>
766
+ <span class="author">${index + 1}👤 ${author}</span>
528
767
  <span class="time">🕒 ${time}</span>
529
768
  </div>
530
769
  <div class="sidebar-item-message">${msg}</div>
@@ -540,16 +779,23 @@ function showSideBarForWeek({ period, weeklyItem, commits = [], titleDrawer }) {
540
779
  }
541
780
 
542
781
  function drawWeeklyTrend(weekly, commits, onWeekClick) {
543
- if (!Array.isArray(weekly) || weekly.length === 0) return null
782
+ const el = document.getElementById('weeklyTrendChart')
783
+ const isEmpty = hideElementByObj({ el, objectName: weekly })
784
+ if (isEmpty) {
785
+ return null
786
+ }
787
+ if (!Array.isArray(weekly) || weekly.length === 0) {
788
+ return null
789
+ }
544
790
 
545
791
  const labels = weekly.map((w) => w.period)
546
792
  const dataRate = weekly.map((w) => +(w.outsideWorkRate * 100).toFixed(1)) // %
547
793
  const dataCount = weekly.map((w) => w.outsideWorkCount)
548
794
 
549
- const el = document.getElementById('weeklyTrendChart')
550
795
  const titleDrawer = el.getAttribute('data-title') || ''
551
796
 
552
797
  const chart = echarts.init(el)
798
+ chartInstances.push(chart)
553
799
 
554
800
  chart.setOption({
555
801
  tooltip: {
@@ -665,15 +911,20 @@ function drawWeeklyTrend(weekly, commits, onWeekClick) {
665
911
  }
666
912
 
667
913
  function drawMonthlyTrend(monthly, commits, onMonthClick) {
914
+ const el = document.getElementById('monthlyTrendChart')
915
+ const isEmpty = hideElementByObj({ el, objectName: monthly })
916
+ if (isEmpty) {
917
+ return null
918
+ }
668
919
  if (!Array.isArray(monthly) || monthly.length === 0) return null
669
920
 
670
921
  const labels = monthly.map((m) => m.period)
671
922
  const dataRate = monthly.map((m) => +(m.outsideWorkRate * 100).toFixed(1)) // 0–100%
672
923
 
673
- const el = document.getElementById('monthlyTrendChart')
674
924
  const titleDrawer = el.getAttribute('data-title') || ''
675
925
  // eslint-disable-next-line no-undef
676
926
  const chart = echarts.init(el)
927
+ chartInstances.push(chart)
677
928
 
678
929
  chart.setOption({
679
930
  tooltip: {
@@ -788,6 +1039,11 @@ function drawMonthlyTrend(monthly, commits, onMonthClick) {
788
1039
  }
789
1040
 
790
1041
  function drawLatestHourDaily(latestByDay, commits, onDayClick) {
1042
+ const el = document.getElementById('latestHourDailyChart')
1043
+ const isEmpty = hideElementByObj({ el, objectName: latestByDay })
1044
+ if (isEmpty) {
1045
+ return null
1046
+ }
791
1047
  if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
792
1048
 
793
1049
  const labels = latestByDay.map((d) => d.date)
@@ -816,11 +1072,11 @@ function drawLatestHourDaily(latestByDay, commits, onDayClick) {
816
1072
  const numericValues = raw.filter((v) => typeof v === 'number')
817
1073
  const maxV = numericValues.length > 0 ? Math.max(...numericValues) : 0
818
1074
 
819
- const el = document.getElementById('latestHourDailyChart')
820
1075
  const titleDrawer = el.getAttribute('data-title') || ''
821
1076
 
822
1077
  // eslint-disable-next-line no-undef
823
1078
  const chart = echarts.init(el)
1079
+ chartInstances.push(chart)
824
1080
 
825
1081
  chart.setOption({
826
1082
  tooltip: {
@@ -932,6 +1188,11 @@ function drawLatestHourDaily(latestByDay, commits, onDayClick) {
932
1188
  }
933
1189
 
934
1190
  function drawDailySeverity(latestByDay, commits, onDayClick) {
1191
+ const el = document.getElementById('dailySeverityChart')
1192
+ const isEmpty = hideElementByObj({ el, objectName: latestByDay })
1193
+ if (isEmpty) {
1194
+ return null
1195
+ }
935
1196
  if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
936
1197
 
937
1198
  const labels = latestByDay.map((d) => d.date)
@@ -947,11 +1208,11 @@ function drawDailySeverity(latestByDay, commits, onDayClick) {
947
1208
  // 这里按 0 小时加班处理,保证折线连续。
948
1209
  const sev = raw.map((v) => (v == null ? 0 : Math.max(0, Number(v) - endH)))
949
1210
 
950
- const el = document.getElementById('dailySeverityChart')
951
1211
  const titleDrawer = el.getAttribute('data-title') || ''
952
1212
 
953
1213
  // eslint-disable-next-line no-undef
954
1214
  const chart = echarts.init(el)
1215
+ chartInstances.push(chart)
955
1216
 
956
1217
  chart.setOption({
957
1218
  tooltip: {
@@ -1105,7 +1366,7 @@ function drawDailyTrendSeverity(commits, weekly, onDayClick) {
1105
1366
 
1106
1367
  // ---------- 3. 自动分析「最累的一周」 ----------
1107
1368
  let maxWeek = null
1108
- if (Array.isArray(weekly)) {
1369
+ if (Array.isArray(weekly) && weekly.length > 0) {
1109
1370
  maxWeek = weekly.reduce((a, b) =>
1110
1371
  a.outsideWorkCount > b.outsideWorkCount ? a : b
1111
1372
  )
@@ -1153,24 +1414,26 @@ function drawDailyTrendSeverity(commits, weekly, onDayClick) {
1153
1414
  const count = params?.[0].value
1154
1415
  const details = dayCommitsDetail[date] || []
1155
1416
 
1156
- let html = `📅 <b>${date}</b><br/>提交次数:${count}<br/><br/>`
1417
+ let html = `📅 <b>${date}</b><br/>提交次数:${count}<br/>`
1157
1418
 
1158
1419
  details.slice(0, 5).forEach((d) => {
1159
- html += `👤 ${d.author}<br/>🕒 ${d.time}<br/>💬 ${d.msg}<br/><br/>`
1420
+ html += `👤 ${d.author}<br/>🕒 ${d.time}<br/> <div class="long-txt-break-all">💬${d.msg}</div>`
1160
1421
  })
1161
1422
 
1162
1423
  if (details.length > 5) {
1163
1424
  html += `(其余 ${details.length - 5} 条已省略)`
1164
1425
  }
1165
-
1426
+ html = `<div class="tooltip-box">${html}</div>`
1166
1427
  return html
1167
1428
  }
1168
1429
 
1169
1430
  // ---------- 7. 绘图 ----------
1170
1431
  const el = document.getElementById('dailyTrendChartDog')
1432
+
1171
1433
  const titleDrawer = el.getAttribute('data-title') || ''
1172
1434
 
1173
1435
  const chart = echarts.init(el)
1436
+ chartInstances.push(chart)
1174
1437
 
1175
1438
  chart.setOption({
1176
1439
  tooltip: {
@@ -1243,10 +1506,10 @@ function showDayDetailSidebar({ date, count, commits, titleDrawer }) {
1243
1506
  // 渲染详情
1244
1507
  content.innerHTML = commits
1245
1508
  .map(
1246
- (c) => `
1509
+ (c, index) => `
1247
1510
  <div class="sidebar-item">
1248
1511
  <div class="sidebar-item-header">
1249
- <span class="author">👤 ${escapeHtml(c.author || 'unknown')}</span>
1512
+ <span class="author">${index + 1}👤 ${escapeHtml(c.author || 'unknown')}</span>
1250
1513
  <span class="time">🕒 ${escapeHtml(c.time || c.date || '')}</span>
1251
1514
  </div>
1252
1515
  <div class="sidebar-item-message">${escapeHtml(c.msg || c.message || '')}</div>
@@ -1312,14 +1575,59 @@ function renderKpi(stats) {
1312
1575
  (latestOut ? new Date(latestOut.date).getHours() : null)
1313
1576
  }
1314
1577
 
1578
+ const htmlLatest = latest
1579
+ ? `<div>最后一次提交时间:${latest ? formatDate(latest.date) : '-'}${typeof latestHour === 'number' ? `(${String(latestHour).padStart(2, '0')}:00)` : ''} <div class="author">${latest?.author}</div> <div class="long-txt"> ${latest?.message} </div></div>`
1580
+ : ``
1581
+
1582
+ // 采样区间展示(来自 config 或 serve 参数),同时支持筛选条件(author)
1583
+ const samplingSince = window.__samplingSince || null
1584
+ const samplingUntil = window.__samplingUntil || null
1585
+ const samplingAuthor = window.__samplingAuthor || null
1586
+ function formatSampling(dStr) {
1587
+ if (!dStr) return null
1588
+ const d = new Date(dStr)
1589
+ if (Number.isNaN(d.valueOf())) return escapeHtml(dStr)
1590
+ return formatDateYMD(d)
1591
+ }
1592
+ let samplingHtml = ''
1593
+ if (samplingSince && samplingUntil) {
1594
+ samplingHtml = `<div class="hr"></div><div class="sampling">采样区间:${formatSampling(samplingSince)} ~ ${formatSampling(samplingUntil)}${samplingAuthor ? ` (作者 ${escapeHtml(samplingAuthor)})` : ''}</div>`
1595
+ } else if (samplingSince) {
1596
+ samplingHtml = `<div class="hr"></div><div class="sampling">采样起始:${formatSampling(samplingSince)}(起)${samplingAuthor ? ` (作者 ${escapeHtml(samplingAuthor)})` : ''}</div>`
1597
+ } else if (samplingUntil) {
1598
+ samplingHtml = `<div class="hr"></div><div class="sampling">采样截止:${formatSampling(samplingUntil)}(止)${samplingAuthor ? ` (作者 ${escapeHtml(samplingAuthor)})` : ''}</div>`
1599
+ } else {
1600
+ samplingHtml = `<div class="hr"></div><div class="sampling">采样区间:全量提交${samplingAuthor ? ` (作者 ${escapeHtml(samplingAuthor)})` : ''}</div>`
1601
+ }
1602
+
1315
1603
  const html = [
1316
- `<div>最晚一次提交时间:${latest ? formatDate(latest.date) : '-'}${typeof latestHour === 'number' ? `(${String(latestHour).padStart(2, '0')}:00)` : ''} <div class="author">${latest.author}</div> <div> ${latest.message} <div></div>`,
1604
+ htmlLatest,
1317
1605
  `<div class="hr"></div>`,
1318
- `<div>加班最晚一次提交时间:${latestOut ? formatDate(latestOut.date) : '-'}${typeof latestOutHour === 'number' ? `(${String(latestOutHour).padStart(2, '0')}:00)` : ''} <div class="author">${latestOut.author}</div> <div>${latestOut.message}</div> </div>`,
1606
+ `<div>加班最晚一次提交时间:${latestOut ? formatDate(latestOut.date) : '-'}${typeof latestOutHour === 'number' ? `(${String(latestOutHour).padStart(2, '0')}:00)` : ''} <div class="author">${latestOut?.author || ''}</div> <div class="long-txt">${latestOut?.message || ''}</div> </div>`,
1319
1607
  `<div class="hr"></div>`,
1320
- `<div>次日归并窗口:凌晨 <b>${cutoff}</b> 点内归前一日</div>`
1608
+ `<div>次日归并窗口:凌晨 <b>${cutoff}</b> 点内归前一日</div>`,
1609
+ samplingHtml
1321
1610
  ].join('')
1322
1611
  el.innerHTML = html
1612
+
1613
+ // 同步显示在 header 侧边的采样信息(更醒目)
1614
+ const headerEl = document.getElementById('samplingInfo')
1615
+ if (headerEl) {
1616
+ const sSince = formatSampling(samplingSince)
1617
+ const sUntil = formatSampling(samplingUntil)
1618
+ const sAuthor = samplingAuthor
1619
+ ? ` (作者 ${escapeHtml(samplingAuthor)})`
1620
+ : ''
1621
+ const sText =
1622
+ sSince && sUntil
1623
+ ? `采样:${sSince} ~ ${sUntil}`
1624
+ : sSince
1625
+ ? `采样起:${sSince}`
1626
+ : sUntil
1627
+ ? `采样止:${sUntil}`
1628
+ : '采样:全量提交'
1629
+ headerEl.textContent = sText + sAuthor
1630
+ }
1323
1631
  }
1324
1632
 
1325
1633
  // 1) 按小时分组(例:commits 为原始提交数组)
@@ -1445,8 +1753,15 @@ function buildDataset(stats, type) {
1445
1753
 
1446
1754
  const drawChangeTrends = (stats) => {
1447
1755
  const el = document.getElementById('chartAuthorChanges')
1756
+ // FIXME: remove debug log before production
1757
+ console.log('❌', 'window.__config', window.__config)
1758
+ if (!window.__config.git.numstat) {
1759
+ hideElementByEl({ el })
1760
+ return null
1761
+ }
1448
1762
  if (!el) return null
1449
1763
  const chart = echarts.init(el)
1764
+ chartInstances.push(chart)
1450
1765
 
1451
1766
  function render(type) {
1452
1767
  const { authors, allPeriods, series } = buildDataset(stats, type)
@@ -1476,12 +1791,16 @@ const drawChangeTrends = (stats) => {
1476
1791
  // extra = `<div style="margin-top:4px;color:#999;font-size:12px">
1477
1792
  // 周区间:${start} ~ ${end}
1478
1793
  // </div>`
1479
- // TODO: remove debug log before production
1480
1794
  extra = ''
1481
1795
  }
1482
1796
 
1483
1797
  const lines = params
1484
1798
  .filter((i) => i.data > 0)
1799
+ .sort(
1800
+ (a, b) =>
1801
+ (b.data || 0) - (a.data || 0) ||
1802
+ String(a.seriesName).localeCompare(String(b.seriesName))
1803
+ )
1485
1804
  .map(
1486
1805
  (item) => `${item.marker}${item.seriesName}: ${item.data} 行变更`
1487
1806
  )
@@ -1514,6 +1833,61 @@ const drawChangeTrends = (stats) => {
1514
1833
  })
1515
1834
  })
1516
1835
 
1836
+ // 点击事件:点击某个作者在某个周期的点,打开侧栏显示该作者在该周期的 commits
1837
+ chart.on('click', (p) => {
1838
+ try {
1839
+ if (!p || p.componentType !== 'series') return
1840
+ const label = p.axisValue || p.name
1841
+ const author = p.seriesName
1842
+ if (!label || !author) return
1843
+ const type =
1844
+ document.querySelector('#tabs button.active')?.dataset.type || 'daily'
1845
+
1846
+ const filteredCommits = (
1847
+ Array.isArray(commitsAll) ? commitsAll : []
1848
+ ).filter((c) => {
1849
+ const a = c.author || 'unknown'
1850
+ if (a !== author) return false
1851
+ const d = new Date(c.date)
1852
+ if (Number.isNaN(d.valueOf())) return false
1853
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
1854
+ if (type === 'weekly') {
1855
+ if (!label.includes('-W')) return false
1856
+ const [yy, ww] = label.split('-W')
1857
+ const range = getISOWeekRange(Number(yy), Number(ww))
1858
+ const day = d.toISOString().slice(0, 10)
1859
+ return day >= range.start && day <= range.end
1860
+ }
1861
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
1862
+ return month === label
1863
+ })
1864
+
1865
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
1866
+
1867
+ if (type === 'weekly') {
1868
+ const weeklyItem = {
1869
+ outsideWorkCount: filteredCommits.length,
1870
+ outsideWorkRate: 0
1871
+ }
1872
+ showSideBarForWeek({
1873
+ period: label,
1874
+ weeklyItem,
1875
+ commits: filteredCommits,
1876
+ titleDrawer: `${author} 变更量 ${type} 详情`
1877
+ })
1878
+ } else {
1879
+ showDayDetailSidebar({
1880
+ date: label,
1881
+ count: filteredCommits.length,
1882
+ commits: filteredCommits,
1883
+ titleDrawer: `${author} 变更量 ${type} 详情`
1884
+ })
1885
+ }
1886
+ } catch (err) {
1887
+ console.warn('Change chart click handler error', err)
1888
+ }
1889
+ })
1890
+
1517
1891
  return chart
1518
1892
  }
1519
1893
 
@@ -1562,6 +1936,7 @@ function drawAuthorOvertimeTrends(commits, stats) {
1562
1936
  const el = document.getElementById('chartAuthorOvertime')
1563
1937
  if (!el) return null
1564
1938
  const chart = echarts.init(el)
1939
+ chartInstances.push(chart)
1565
1940
 
1566
1941
  const startHour =
1567
1942
  typeof stats.startHour === 'number' && stats.startHour >= 0
@@ -1609,6 +1984,11 @@ function drawAuthorOvertimeTrends(commits, stats) {
1609
1984
 
1610
1985
  const lines = params
1611
1986
  .filter((i) => i.data > 0)
1987
+ .sort(
1988
+ (a, b) =>
1989
+ (b.data || 0) - (a.data || 0) ||
1990
+ String(a.seriesName).localeCompare(String(b.seriesName))
1991
+ )
1612
1992
  .map(
1613
1993
  (item) => `${item.marker}${item.seriesName}: ${item.data} 次提交`
1614
1994
  )
@@ -1643,13 +2023,75 @@ function drawAuthorOvertimeTrends(commits, stats) {
1643
2023
  })
1644
2024
  })
1645
2025
 
1646
- // 输出本周风险总结
2026
+ // 输出本周风险与加班时长排行
1647
2027
  renderWeeklyRiskSummary(commits, { startHour, endHour, cutoff })
1648
2028
  renderMonthlyRiskSummary(commits, { startHour, endHour, cutoff })
2029
+ // 新增:本周/本月加班时长排名(显示所有作者总时长,前三名带图标,状元标注“夜魔侠”)
2030
+ renderWeeklyDurationRankSummary(commits, { startHour, endHour, cutoff })
1649
2031
  renderWeeklyDurationRiskSummary(commits, { startHour, endHour, cutoff })
2032
+ renderMonthlyDurationRankSummary(commits, { startHour, endHour, cutoff })
1650
2033
  renderMonthlyDurationRiskSummary(commits, { startHour, endHour, cutoff })
1651
2034
  renderRolling30DurationRiskSummary(commits, { startHour, endHour, cutoff })
1652
2035
 
2036
+ // 点击事件:点击某个作者在某周期的点,打开侧栏显示该作者在该周期内的下班后提交(加班)明细
2037
+ chart.on('click', (p) => {
2038
+ try {
2039
+ if (!p || p.componentType !== 'series') return
2040
+ const label = p.axisValue || p.name
2041
+ const author = p.seriesName
2042
+ if (!label || !author) return
2043
+ const type =
2044
+ document.querySelector('#tabsOvertime button.active')?.dataset.type ||
2045
+ 'daily'
2046
+
2047
+ const filteredCommits = commits.filter((c) => {
2048
+ const a = c.author || 'unknown'
2049
+ if (a !== author) return false
2050
+ const d = new Date(c.date)
2051
+ if (Number.isNaN(d.valueOf())) return false
2052
+ const h = d.getHours()
2053
+ const isOT =
2054
+ (h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
2055
+ if (!isOT) return false
2056
+
2057
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
2058
+ if (type === 'weekly') {
2059
+ if (!label.includes('-W')) return false
2060
+ const [yy, ww] = label.split('-W')
2061
+ const range = getISOWeekRange(Number(yy), Number(ww))
2062
+ const day = d.toISOString().slice(0, 10)
2063
+ return day >= range.start && day <= range.end
2064
+ }
2065
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2066
+ return month === label
2067
+ })
2068
+
2069
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
2070
+
2071
+ if (type === 'weekly') {
2072
+ const weeklyItem = {
2073
+ outsideWorkCount: filteredCommits.length,
2074
+ outsideWorkRate: 0
2075
+ }
2076
+ showSideBarForWeek({
2077
+ period: label,
2078
+ weeklyItem,
2079
+ commits: filteredCommits,
2080
+ titleDrawer: `${author} 加班本周详情`
2081
+ })
2082
+ } else {
2083
+ showDayDetailSidebar({
2084
+ date: label,
2085
+ count: filteredCommits.length,
2086
+ commits: filteredCommits,
2087
+ titleDrawer: `${author} 加班 ${type} 详情`
2088
+ })
2089
+ }
2090
+ } catch (err) {
2091
+ console.warn('Overtime chart click handler error', err)
2092
+ }
2093
+ })
2094
+
1653
2095
  return chart
1654
2096
  }
1655
2097
 
@@ -1737,6 +2179,65 @@ function renderWeeklyRiskSummary(
1737
2179
  )
1738
2180
  }
1739
2181
 
2182
+ // ---------- 追加:本周“最晚加班风险”分析(合并自原 renderLatestRiskSummary) ----------
2183
+ const weekMax = new Map()
2184
+ commits.forEach((c) => {
2185
+ const d = new Date(c.date)
2186
+ if (Number.isNaN(d.valueOf())) return
2187
+ const h = d.getHours()
2188
+ let overtime = null
2189
+ if (h >= endHour && h < 24) overtime = h - endHour
2190
+ else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
2191
+ if (overtime == null) return
2192
+
2193
+ const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
2194
+ if (!wKey) return
2195
+ if (!weekMax.has(wKey)) weekMax.set(wKey, new Map())
2196
+ const m = weekMax.get(wKey)
2197
+ const author = c.author || 'unknown'
2198
+ const cur = m.get(author)
2199
+ if (!cur || overtime > cur.max) {
2200
+ m.set(author, { max: overtime, date: d.toISOString().slice(0, 10) })
2201
+ }
2202
+ })
2203
+
2204
+ const curMaxMap = weekMax.get(curKey) || new Map()
2205
+ const prevMaxMap = weekMax.get(prevKey) || new Map()
2206
+ let topAuthorLatest = null
2207
+ let topLatest = { max: -1, date: null }
2208
+ curMaxMap.forEach((v, k) => {
2209
+ if (v.max > topLatest.max) {
2210
+ topLatest = v
2211
+ topAuthorLatest = k
2212
+ }
2213
+ })
2214
+ let prevMaxLatest = -1
2215
+ prevMaxMap.forEach((v) => {
2216
+ if (v.max > prevMaxLatest) prevMaxLatest = v.max
2217
+ })
2218
+
2219
+ const latestLines = []
2220
+ latestLines.push('【本周最晚加班风险】')
2221
+
2222
+ if (topLatest.max < 0) {
2223
+ latestLines.push('本周尚无下班后/凌晨提交,未发现明显风险。')
2224
+ } else {
2225
+ let trend2 = '暂无上周对比'
2226
+ if (prevMaxLatest >= 0) {
2227
+ if (topLatest.max > prevMaxLatest) trend2 = '较上周更晚'
2228
+ else if (topLatest.max < prevMaxLatest) trend2 = '较上周提前'
2229
+ else trend2 = '与上周持平'
2230
+ }
2231
+ latestLines.push(
2232
+ `${topAuthorLatest} 本周最晚超出下班 ${topLatest.max.toFixed(2)} 小时(${topLatest.date}),${trend2}。`
2233
+ )
2234
+ if (topLatest.max >= 2) {
2235
+ latestLines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
2236
+ } else if (topLatest.max >= 1) {
2237
+ latestLines.push('已超过 1 小时,注意控制夜间工作时长。')
2238
+ }
2239
+ }
2240
+
1740
2241
  box.innerHTML = `
1741
2242
  <div class="risk-summary">
1742
2243
  <div class="risk-title">【本周风险总结】</div>
@@ -1747,6 +2248,16 @@ function renderWeeklyRiskSummary(
1747
2248
  .join('')}
1748
2249
  </ul>
1749
2250
  </div>
2251
+
2252
+ <div class="risk-summary">
2253
+ <div class="risk-title">【本周最晚加班风险】</div>
2254
+ <ul>
2255
+ ${latestLines
2256
+ .slice(1)
2257
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2258
+ .join('')}
2259
+ </ul>
2260
+ </div>
1750
2261
  `
1751
2262
  }
1752
2263
 
@@ -1779,6 +2290,62 @@ function computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff) {
1779
2290
  return byAuthorDay
1780
2291
  }
1781
2292
 
2293
+ function renderWeeklyDurationRankSummary(
2294
+ commits,
2295
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2296
+ ) {
2297
+ const box = document.getElementById('weeklyDurationRankSummary')
2298
+ if (!box) return
2299
+ const now = new Date()
2300
+ const curWeek = getIsoWeekKey(now.toISOString().slice(0, 10))
2301
+ const byAuthorDay = computeAuthorDailyMaxOvertime(
2302
+ commits,
2303
+ startHour,
2304
+ endHour,
2305
+ cutoff
2306
+ )
2307
+ const ranks = []
2308
+ byAuthorDay.forEach((dayMap, author) => {
2309
+ let total = 0
2310
+ dayMap.forEach((v, dayKey) => {
2311
+ const wk = getIsoWeekKey(dayKey)
2312
+ if (wk === curWeek) total += v
2313
+ })
2314
+ if (total > 0) ranks.push({ author, total })
2315
+ })
2316
+ ranks.sort(
2317
+ (a, b) =>
2318
+ b.total - a.total || String(a.author).localeCompare(String(b.author))
2319
+ )
2320
+
2321
+ const lines = []
2322
+ lines.push('【本周加班时长排名】')
2323
+ if (ranks.length === 0) {
2324
+ lines.push('本周暂无加班时长。')
2325
+ } else {
2326
+ ranks.forEach((r, idx) => {
2327
+ const rank = idx + 1
2328
+ const medal =
2329
+ rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
2330
+ const title = rank === 1 ? '(状元・夜魔侠)' : ''
2331
+ lines.push(
2332
+ `${rank}. ${medal}${r.author} — ${r.total.toFixed(2)} 小时${title}`
2333
+ )
2334
+ })
2335
+ }
2336
+ box.innerHTML = `
2337
+ <div class="risk-summary">
2338
+ <div class="risk-title">【本周加班时长排名】</div>
2339
+ <ul>
2340
+ ${lines
2341
+ .slice(1)
2342
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2343
+ .join('')}
2344
+ </ul>
2345
+ </div>
2346
+ `
2347
+ }
2348
+
1782
2349
  function renderWeeklyDurationRiskSummary(
1783
2350
  commits,
1784
2351
  { startHour = 9, endHour = 18, cutoff = 6 } = {}
@@ -1831,11 +2398,11 @@ function renderWeeklyDurationRiskSummary(
1831
2398
  `
1832
2399
  }
1833
2400
 
1834
- function renderMonthlyDurationRiskSummary(
2401
+ function renderMonthlyDurationRankSummary(
1835
2402
  commits,
1836
2403
  { startHour = 9, endHour = 18, cutoff = 6 } = {}
1837
2404
  ) {
1838
- const box = document.getElementById('monthlyDurationRiskSummary')
2405
+ const box = document.getElementById('monthlyDurationRankSummary')
1839
2406
  if (!box) return
1840
2407
  const now = new Date()
1841
2408
  const curMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
@@ -1845,34 +2412,38 @@ function renderMonthlyDurationRiskSummary(
1845
2412
  endHour,
1846
2413
  cutoff
1847
2414
  )
1848
- const sums = []
2415
+ const ranks = []
1849
2416
  byAuthorDay.forEach((dayMap, author) => {
1850
2417
  let total = 0
1851
2418
  dayMap.forEach((v, dayKey) => {
1852
2419
  const m = dayKey.slice(0, 7)
1853
2420
  if (m === curMonth) total += v
1854
2421
  })
1855
- if (total > 0) sums.push({ author, total })
2422
+ if (total > 0) ranks.push({ author, total })
1856
2423
  })
1857
- sums.sort((a, b) => b.total - a.total)
1858
- const top = sums.slice(0, 6)
2424
+ ranks.sort(
2425
+ (a, b) =>
2426
+ b.total - a.total || String(a.author).localeCompare(String(b.author))
2427
+ )
2428
+
1859
2429
  const lines = []
1860
- lines.push('【本月加班时长风险】')
1861
- if (top.length === 0) {
1862
- lines.push('本月暂无加班时长风险。')
2430
+ lines.push('【本月加班时长排名】')
2431
+ if (ranks.length === 0) {
2432
+ lines.push('本月暂无加班时长。')
1863
2433
  } else {
1864
- top.forEach(({ author, total }) => {
1865
- let level = '轻度'
1866
- if (total >= 20) level = '严重'
1867
- else if (total >= 10) level = '中度'
2434
+ ranks.forEach((r, idx) => {
2435
+ const rank = idx + 1
2436
+ const medal =
2437
+ rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
2438
+ const title = rank === 1 ? '(状元・夜魔侠)' : ''
1868
2439
  lines.push(
1869
- `${author} 本月累计加班 ${total.toFixed(2)} 小时(${level})。`
2440
+ `${rank}. ${medal}${r.author} ${r.total.toFixed(2)} 小时${title}`
1870
2441
  )
1871
2442
  })
1872
2443
  }
1873
2444
  box.innerHTML = `
1874
2445
  <div class="risk-summary">
1875
- <div class="risk-title">【本月加班时长风险】</div>
2446
+ <div class="risk-title">【本月加班时长排名】</div>
1876
2447
  <ul>
1877
2448
  ${lines
1878
2449
  .slice(1)
@@ -1883,19 +2454,14 @@ function renderMonthlyDurationRiskSummary(
1883
2454
  `
1884
2455
  }
1885
2456
 
1886
- function renderRolling30DurationRiskSummary(
2457
+ function renderMonthlyDurationRiskSummary(
1887
2458
  commits,
1888
2459
  { startHour = 9, endHour = 18, cutoff = 6 } = {}
1889
2460
  ) {
1890
- const box = document.getElementById('rolling30DurationRiskSummary')
2461
+ const box = document.getElementById('monthlyDurationRiskSummary')
1891
2462
  if (!box) return
1892
2463
  const now = new Date()
1893
- const utcToday = new Date(
1894
- Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
1895
- )
1896
- utcToday.setUTCDate(utcToday.getUTCDate() - 29)
1897
- const startKey = utcToday.toISOString().slice(0, 10)
1898
-
2464
+ const curMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
1899
2465
  const byAuthorDay = computeAuthorDailyMaxOvertime(
1900
2466
  commits,
1901
2467
  startHour,
@@ -1906,23 +2472,80 @@ function renderRolling30DurationRiskSummary(
1906
2472
  byAuthorDay.forEach((dayMap, author) => {
1907
2473
  let total = 0
1908
2474
  dayMap.forEach((v, dayKey) => {
1909
- if (dayKey >= startKey) total += v
2475
+ const m = dayKey.slice(0, 7)
2476
+ if (m === curMonth) total += v
1910
2477
  })
1911
2478
  if (total > 0) sums.push({ author, total })
1912
2479
  })
1913
2480
  sums.sort((a, b) => b.total - a.total)
1914
2481
  const top = sums.slice(0, 6)
1915
2482
  const lines = []
1916
- lines.push('【最近30天加班时长风险】')
2483
+ lines.push('【本月加班时长风险】')
1917
2484
  if (top.length === 0) {
1918
- lines.push('最近30天暂无加班时长风险。')
2485
+ lines.push('本月暂无加班时长风险。')
1919
2486
  } else {
1920
2487
  top.forEach(({ author, total }) => {
1921
2488
  let level = '轻度'
1922
2489
  if (total >= 20) level = '严重'
1923
2490
  else if (total >= 10) level = '中度'
1924
2491
  lines.push(
1925
- `${author} 最近30天累计加班 ${total.toFixed(2)} 小时(${level})。`
2492
+ `${author} 本月累计加班 ${total.toFixed(2)} 小时(${level})。`
2493
+ )
2494
+ })
2495
+ }
2496
+ box.innerHTML = `
2497
+ <div class="risk-summary">
2498
+ <div class="risk-title">【本月加班时长风险】</div>
2499
+ <ul>
2500
+ ${lines
2501
+ .slice(1)
2502
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2503
+ .join('')}
2504
+ </ul>
2505
+ </div>
2506
+ `
2507
+ }
2508
+
2509
+ function renderRolling30DurationRiskSummary(
2510
+ commits,
2511
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2512
+ ) {
2513
+ const box = document.getElementById('rolling30DurationRiskSummary')
2514
+ if (!box) return
2515
+ const now = new Date()
2516
+ const utcToday = new Date(
2517
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
2518
+ )
2519
+ utcToday.setUTCDate(utcToday.getUTCDate() - 29)
2520
+ const startKey = utcToday.toISOString().slice(0, 10)
2521
+
2522
+ const byAuthorDay = computeAuthorDailyMaxOvertime(
2523
+ commits,
2524
+ startHour,
2525
+ endHour,
2526
+ cutoff
2527
+ )
2528
+ const sums = []
2529
+ byAuthorDay.forEach((dayMap, author) => {
2530
+ let total = 0
2531
+ dayMap.forEach((v, dayKey) => {
2532
+ if (dayKey >= startKey) total += v
2533
+ })
2534
+ if (total > 0) sums.push({ author, total })
2535
+ })
2536
+ sums.sort((a, b) => b.total - a.total)
2537
+ const top = sums.slice(0, 6)
2538
+ const lines = []
2539
+ lines.push('【最近30天加班时长风险】')
2540
+ if (top.length === 0) {
2541
+ lines.push('最近30天暂无加班时长风险。')
2542
+ } else {
2543
+ top.forEach(({ author, total }) => {
2544
+ let level = '轻度'
2545
+ if (total >= 20) level = '严重'
2546
+ else if (total >= 10) level = '中度'
2547
+ lines.push(
2548
+ `${author} 最近30天累计加班 ${total.toFixed(2)} 小时(${level})。`
1926
2549
  )
1927
2550
  })
1928
2551
  }
@@ -2035,6 +2658,28 @@ function renderMonthlyRiskSummary(
2035
2658
  }
2036
2659
  }
2037
2660
 
2661
+ // ---------- 追加:本月“最晚加班风险”独立展示(合并自 renderLatestMonthlyRiskSummary) ----------
2662
+ const latestMonthLines = []
2663
+ latestMonthLines.push('【本月最晚加班风险】')
2664
+ if (top.max < 0) {
2665
+ latestMonthLines.push('本月尚无下班后/凌晨提交,未发现明显风险。')
2666
+ } else {
2667
+ let trend3 = '暂无上月对比'
2668
+ if (prevMax >= 0) {
2669
+ if (top.max > prevMax) trend3 = '较上月更晚'
2670
+ else if (top.max < prevMax) trend3 = '较上月提前'
2671
+ else trend3 = '与上月持平'
2672
+ }
2673
+ latestMonthLines.push(
2674
+ `${topAuthor} 本月最晚超出下班 ${top.max.toFixed(2)} 小时(${top.date}),${trend3}。`
2675
+ )
2676
+ if (top.max >= 2) {
2677
+ latestMonthLines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
2678
+ } else if (top.max >= 1) {
2679
+ latestMonthLines.push('已超过 1 小时,注意控制夜间工作时长。')
2680
+ }
2681
+ }
2682
+
2038
2683
  box.innerHTML = `
2039
2684
  <div class="risk-summary">
2040
2685
  <div class="risk-title">【本月加班风险】</div>
@@ -2045,6 +2690,16 @@ function renderMonthlyRiskSummary(
2045
2690
  .join('')}
2046
2691
  </ul>
2047
2692
  </div>
2693
+
2694
+ <div class="risk-summary">
2695
+ <div class="risk-title">【本月最晚加班风险】</div>
2696
+ <ul>
2697
+ ${latestMonthLines
2698
+ .slice(1)
2699
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2700
+ .join('')}
2701
+ </ul>
2702
+ </div>
2048
2703
  `
2049
2704
  }
2050
2705
 
@@ -2096,6 +2751,7 @@ function drawAuthorLatestOvertimeTrends(commits, stats) {
2096
2751
  const el = document.getElementById('chartAuthorLatestOvertime')
2097
2752
  if (!el) return null
2098
2753
  const chart = echarts.init(el)
2754
+ chartInstances.push(chart)
2099
2755
 
2100
2756
  const startHour =
2101
2757
  typeof stats.startHour === 'number' && stats.startHour >= 0
@@ -2143,6 +2799,11 @@ function drawAuthorLatestOvertimeTrends(commits, stats) {
2143
2799
 
2144
2800
  const lines = params
2145
2801
  .filter((i) => i.data > 0)
2802
+ .sort(
2803
+ (a, b) =>
2804
+ (b.data || 0) - (a.data || 0) ||
2805
+ String(a.seriesName).localeCompare(String(b.seriesName))
2806
+ )
2146
2807
  .map(
2147
2808
  (item) => `${item.marker}${item.seriesName}: ${item.data} 小时`
2148
2809
  )
@@ -2177,144 +2838,1936 @@ function drawAuthorLatestOvertimeTrends(commits, stats) {
2177
2838
  })
2178
2839
  })
2179
2840
 
2180
- renderLatestRiskSummary(commits, { startHour, endHour, cutoff })
2181
- renderLatestMonthlyRiskSummary(commits, { startHour, endHour, cutoff })
2841
+ // 点击事件:点击某个作者在某周期的点,打开侧栏显示该作者在该周期内的加班提交明细(用于查看具体提交与时间)
2842
+ chart.on('click', (p) => {
2843
+ try {
2844
+ if (!p || p.componentType !== 'series') return
2845
+ const label = p.axisValue || p.name
2846
+ const author = p.seriesName
2847
+ if (!label || !author) return
2848
+ const type =
2849
+ document.querySelector('#tabsLatestOvertime button.active')?.dataset
2850
+ .type || 'daily'
2851
+
2852
+ const filteredCommits = commits.filter((c) => {
2853
+ const a = c.author || 'unknown'
2854
+ if (a !== author) return false
2855
+ const d = new Date(c.date)
2856
+ if (Number.isNaN(d.valueOf())) return false
2857
+ const h = d.getHours()
2858
+ let overtime = null
2859
+ if (h >= endHour && h < 24) overtime = h - endHour
2860
+ else if (h >= 0 && h < cutoff && h < startHour)
2861
+ overtime = 24 - endHour + h
2862
+ if (overtime == null) return false
2863
+
2864
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
2865
+ if (type === 'weekly') {
2866
+ if (!label.includes('-W')) return false
2867
+ const [yy, ww] = label.split('-W')
2868
+ const range = getISOWeekRange(Number(yy), Number(ww))
2869
+ const day = d.toISOString().slice(0, 10)
2870
+ return day >= range.start && day <= range.end
2871
+ }
2872
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2873
+ return month === label
2874
+ })
2875
+
2876
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
2877
+
2878
+ if (type === 'weekly') {
2879
+ const weeklyItem = {
2880
+ outsideWorkCount: filteredCommits.length,
2881
+ outsideWorkRate: 0
2882
+ }
2883
+ showSideBarForWeek({
2884
+ period: label,
2885
+ weeklyItem,
2886
+ commits: filteredCommits,
2887
+ titleDrawer: `${author} 本周最晚加班详情`
2888
+ })
2889
+ } else {
2890
+ showDayDetailSidebar({
2891
+ date: label,
2892
+ count: filteredCommits.length,
2893
+ commits: filteredCommits,
2894
+ titleDrawer: `${author} 本日最晚加班详情`
2895
+ })
2896
+ }
2897
+ } catch (err) {
2898
+ console.warn('Latest overtime chart click handler error', err)
2899
+ }
2900
+ })
2182
2901
 
2183
2902
  return chart
2184
2903
  }
2185
2904
 
2186
- // 本周“最晚加班”风险提示
2187
- function renderLatestRiskSummary(
2905
+ // ====== 开发者 累计加班时长(按日/周/月/年累计日峰值加班时长求和) ======
2906
+ function buildAuthorTotalOvertimeDataset(
2188
2907
  commits,
2189
- { startHour = 9, endHour = 18, cutoff = 6 } = {}
2908
+ type,
2909
+ startHour,
2910
+ endHour,
2911
+ cutoff
2190
2912
  ) {
2191
- const box = document.getElementById('latestRiskSummary')
2192
- if (!box) return
2193
-
2194
- const now = new Date()
2195
- const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
2196
- const prev = new Date(now)
2197
- prev.setDate(prev.getDate() - 7)
2198
- const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
2199
-
2200
- // 统计每周每人最大超时
2201
- const weekMax = new Map() // week -> Map(author -> {max, date})
2202
- commits.forEach((c) => {
2203
- const d = new Date(c.date)
2204
- if (Number.isNaN(d.valueOf())) return
2205
- const h = d.getHours()
2206
- let overtime = null
2207
- if (h >= endHour && h < 24) overtime = h - endHour
2208
- else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
2209
- if (overtime == null) return
2913
+ // 基于每天每人的最大超时(computeAuthorDailyMaxOvertime),再按周期聚合求和
2914
+ const byAuthorDay = computeAuthorDailyMaxOvertime(
2915
+ commits,
2916
+ startHour,
2917
+ endHour,
2918
+ cutoff
2919
+ )
2920
+ const byAuthorPeriod = new Map()
2921
+ const periods = new Set()
2210
2922
 
2211
- const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
2212
- if (!wKey) return
2213
- if (!weekMax.has(wKey)) weekMax.set(wKey, new Map())
2214
- const m = weekMax.get(wKey)
2215
- const author = c.author || 'unknown'
2216
- const cur = m.get(author)
2217
- if (!cur || overtime > cur.max) {
2218
- m.set(author, { max: overtime, date: d.toISOString().slice(0, 10) })
2923
+ byAuthorDay.forEach((dayMap, author) => {
2924
+ for (const [dayKey, overtime] of dayMap.entries()) {
2925
+ let period
2926
+ if (type === 'daily') period = dayKey
2927
+ else if (type === 'weekly') period = getIsoWeekKey(dayKey)
2928
+ else if (type === 'monthly')
2929
+ period = dayKey.slice(0, 7) // YYYY-MM
2930
+ else if (type === 'yearly')
2931
+ period = dayKey.slice(0, 4) // YYYY
2932
+ else period = dayKey
2933
+ if (!period) continue
2934
+ periods.add(period)
2935
+ if (!byAuthorPeriod.has(author)) byAuthorPeriod.set(author, {})
2936
+ const obj = byAuthorPeriod.get(author)
2937
+ obj[period] = (obj[period] || 0) + (overtime || 0)
2219
2938
  }
2220
2939
  })
2221
2940
 
2222
- const curMap = weekMax.get(curKey) || new Map()
2223
- const prevMap = weekMax.get(prevKey) || new Map()
2941
+ const allPeriods = Array.from(periods).sort()
2942
+ const authors = Array.from(byAuthorPeriod.keys()).sort()
2943
+ const series = authors.map((a) => ({
2944
+ name: a,
2945
+ type: 'line',
2946
+ smooth: true,
2947
+ data: allPeriods.map((p) =>
2948
+ Number((byAuthorPeriod.get(a)[p] || 0).toFixed(2))
2949
+ )
2950
+ }))
2224
2951
 
2225
- // 当前周的全局最晚
2226
- let topAuthor = null
2227
- let top = { max: -1, date: null }
2228
- curMap.forEach((v, k) => {
2229
- if (v.max > top.max) {
2230
- top = v
2231
- topAuthor = k
2232
- }
2952
+ // 3. 计算每个作者的总时长/占用天数/日均占用,方便列表与 tooltip 使用
2953
+ const totals = authors.map((a) => {
2954
+ const periodObj = byAuthorPeriod.get(a) || {}
2955
+ const totalHours = Object.values(periodObj).reduce(
2956
+ (s, v) => s + (Number(v) || 0),
2957
+ 0
2958
+ )
2959
+ // days 从 byAuthorDay(每日最大超时的 map)中获取
2960
+ const days = byAuthorDay.get(a) ? byAuthorDay.get(a).size : 0
2961
+ const avg = days > 0 ? Number((totalHours / days).toFixed(2)) : 0
2962
+ return { author: a, totalHours: Number(totalHours.toFixed(2)), days, avg }
2233
2963
  })
2234
2964
 
2235
- // 上周全局最晚,用于趋势判断
2236
- let prevMax = -1
2237
- prevMap.forEach((v) => {
2238
- if (v.max > prevMax) prevMax = v.max
2239
- })
2965
+ return { authors, allPeriods, series, totals }
2966
+ }
2240
2967
 
2241
- const lines = []
2242
- lines.push('【本周最晚加班风险】')
2968
+ function drawAuthorTotalOvertimeTrends(commits, stats) {
2969
+ const el = document.getElementById('chartAuthorTotalOvertime')
2970
+ if (!el) return null
2971
+ const chart = echarts.init(el)
2972
+ chartInstances.push(chart)
2243
2973
 
2244
- if (top.max < 0) {
2245
- lines.push('本周尚无下班后/凌晨提交,未发现明显风险。')
2246
- } else {
2247
- let trend = '暂无上周对比'
2248
- if (prevMax >= 0) {
2249
- if (top.max > prevMax) trend = '较上周更晚'
2250
- else if (top.max < prevMax) trend = '较上周提前'
2251
- else trend = '与上周持平'
2252
- }
2253
- lines.push(
2254
- `${topAuthor} 本周最晚超出下班 ${top.max.toFixed(
2255
- 2
2256
- )} 小时(${top.date}),${trend}。`
2974
+ const startHour =
2975
+ typeof stats.startHour === 'number' && stats.startHour >= 0
2976
+ ? stats.startHour
2977
+ : 9
2978
+ const endHour =
2979
+ typeof stats.endHour === 'number' && stats.endHour >= 0
2980
+ ? stats.endHour
2981
+ : window.__overtimeEndHour || 18
2982
+ const cutoff = window.__overnightCutoff ?? 6
2983
+
2984
+ function render(type) {
2985
+ const ds = buildAuthorTotalOvertimeDataset(
2986
+ commits,
2987
+ type,
2988
+ startHour,
2989
+ endHour,
2990
+ cutoff
2257
2991
  )
2258
- if (top.max >= 2) {
2259
- lines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
2260
- } else if (top.max >= 1) {
2261
- lines.push('已超过 1 小时,注意控制夜间工作时长。')
2992
+ ds.rangeMap = {}
2993
+ for (const period of ds.allPeriods) {
2994
+ if (period.includes('-W')) {
2995
+ const [yy, ww] = period.split('-W')
2996
+ ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
2997
+ }
2262
2998
  }
2263
- }
2264
-
2265
- box.innerHTML = `
2266
- <div class="risk-summary">
2267
- <div class="risk-title">【本周最晚加班风险】</div>
2268
- <ul>
2269
- ${lines
2270
- .slice(1)
2271
- .map((l) => `<li>${escapeHtml(l)}</li>`)
2272
- .join('')}
2273
- </ul>
2274
- </div>
2275
- `
2276
- }
2277
2999
 
2278
- function renderLatestMonthlyRiskSummary(
2279
- commits,
2280
- { startHour = 9, endHour = 18, cutoff = 6 } = {}
2281
- ) {
2282
- const box = document.getElementById('latestMonthlyRiskSummary')
2283
- if (!box) return
3000
+ chart.setOption({
3001
+ tooltip: {
3002
+ trigger: 'axis',
3003
+ formatter(params) {
3004
+ if (!params || !params.length) return ''
3005
+ const label = params[0].axisValue
3006
+ const isWeekly = type === 'weekly'
3007
+ let extra = ''
3008
+ if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
3009
+ const { start, end } = ds.rangeMap[label]
3010
+ extra = `<div style="margin-top:4px;color:#999;font-size:12px">周区间:${start} ~ ${end}</div>`
3011
+ }
3012
+ const lines = params
3013
+ .filter((i) => i.data > 0)
3014
+ .sort(
3015
+ (a, b) =>
3016
+ (b.data || 0) - (a.data || 0) ||
3017
+ String(a.seriesName).localeCompare(String(b.seriesName))
3018
+ )
3019
+ .map(
3020
+ (item) => `${item.marker}${item.seriesName}: ${item.data} 小时`
3021
+ )
3022
+ .join('<br/>')
3023
+ return `<div>${label}</div>${extra}${lines}`
3024
+ }
3025
+ },
3026
+ legend: { data: ds.authors },
3027
+ xAxis: { type: 'category', data: ds.allPeriods },
3028
+ yAxis: { type: 'value', name: '累计加班时长 (小时)' },
3029
+ series: ds.series
3030
+ })
2284
3031
 
2285
- const now = new Date()
2286
- const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
2287
- const prev = new Date(now)
2288
- prev.setMonth(prev.getMonth() - 1)
2289
- const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
3032
+ // 同步更新下面的排名列表
3033
+ try {
3034
+ renderAuthorTotalOvertimeRankFromDs(ds, 0)
3035
+ renderAuthorTotalOvertimeRank(ds, 0)
3036
+ } catch (e) {
3037
+ console.warn('更新累计加班排名失败', e)
3038
+ }
3039
+ }
2290
3040
 
2291
- const monthMax = new Map()
2292
- commits.forEach((c) => {
2293
- const d = new Date(c.date)
2294
- if (Number.isNaN(d.valueOf())) return
2295
- const h = d.getHours()
2296
- let overtime = null
2297
- if (h >= endHour && h < 24) overtime = h - endHour
2298
- else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
2299
- if (overtime == null) return
3041
+ render('daily')
2300
3042
 
2301
- const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2302
- if (!monthMax.has(mKey)) monthMax.set(mKey, new Map())
2303
- const m = monthMax.get(mKey)
2304
- const author = c.author || 'unknown'
2305
- const cur = m.get(author)
2306
- if (!cur || overtime > cur.max) {
2307
- m.set(author, { max: overtime, date: d.toISOString().slice(0, 10) })
2308
- }
3043
+ const tabs = document.querySelectorAll('#tabsTotalOvertime button')
3044
+ tabs.forEach((btnEl) => {
3045
+ btnEl.addEventListener('click', () => {
3046
+ tabs.forEach((b) => b.classList.remove('active'))
3047
+ btnEl.classList.add('active')
3048
+ render(btnEl.dataset.type)
3049
+ })
2309
3050
  })
2310
3051
 
2311
- const curMap = monthMax.get(curKey) || new Map()
2312
- const prevMap = monthMax.get(prevKey) || new Map()
2313
-
2314
- let topAuthor = null
2315
- let top = { max: -1, date: null }
2316
- curMap.forEach((v, k) => {
2317
- if (v.max > top.max) {
3052
+ // 点击事件:展示该作者在该周期的加班详情(过滤出下班/凌晨提交)
3053
+ chart.on('click', (p) => {
3054
+ try {
3055
+ if (!p || p.componentType !== 'series') return
3056
+ const label = p.axisValue || p.name
3057
+ const author = p.seriesName
3058
+ if (!label || !author) return
3059
+ const type =
3060
+ document.querySelector('#tabsTotalOvertime button.active')?.dataset
3061
+ .type || 'daily'
3062
+
3063
+ const filteredCommits = commits.filter((c) => {
3064
+ const a = c.author || 'unknown'
3065
+ if (a !== author) return false
3066
+ const d = new Date(c.date)
3067
+ if (Number.isNaN(d.valueOf())) return false
3068
+ const h = d.getHours()
3069
+ const isOT =
3070
+ (h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
3071
+ if (!isOT) return false
3072
+
3073
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
3074
+ if (type === 'weekly') {
3075
+ if (!label.includes('-W')) return false
3076
+ const [yy, ww] = label.split('-W')
3077
+ const range = getISOWeekRange(Number(yy), Number(ww))
3078
+ const day = d.toISOString().slice(0, 10)
3079
+ return day >= range.start && day <= range.end
3080
+ }
3081
+ if (type === 'monthly') {
3082
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
3083
+ return month === label
3084
+ }
3085
+ // yearly
3086
+ const year = String(d.getFullYear())
3087
+ return year === label
3088
+ })
3089
+
3090
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
3091
+
3092
+ if (type === 'weekly') {
3093
+ const weeklyItem = {
3094
+ outsideWorkCount: filteredCommits.length,
3095
+ outsideWorkRate: 0
3096
+ }
3097
+ showSideBarForWeek({
3098
+ period: label,
3099
+ weeklyItem,
3100
+ commits: filteredCommits,
3101
+ titleDrawer: `${author} 累计加班 本周详情`
3102
+ })
3103
+ } else {
3104
+ showDayDetailSidebar({
3105
+ date: label,
3106
+ count: filteredCommits.length,
3107
+ commits: filteredCommits,
3108
+ titleDrawer: `${author} 累计加班 ${type} 详情`
3109
+ })
3110
+ }
3111
+ } catch (err) {
3112
+ console.warn('Total overtime chart click handler error', err)
3113
+ }
3114
+ })
3115
+
3116
+ return chart
3117
+ }
3118
+
3119
+ /**
3120
+ * 渲染作者加班时长分布饼图
3121
+ * @param {Object} ds - 数据源
3122
+ * @param {Number} topN - 饼图展示的前N名,默认10,设为0则展示全部(不推荐在饼图中设为0)
3123
+ */
3124
+ function renderAuthorTotalOvertimeRank(ds, topN = 10) {
3125
+ // FIXME: remove debug log before production
3126
+ console.log('❌', 'ds', ds)
3127
+ if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) return
3128
+
3129
+ // 1. 数据预处理:计算每个作者的总时长
3130
+ const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
3131
+
3132
+ const totals = ds.authors.map((author) => {
3133
+ const data = seriesMap.get(author)
3134
+ const total = Array.isArray(data)
3135
+ ? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
3136
+ : 0
3137
+ return { name: author, value: Number(total.toFixed(2)) }
3138
+ })
3139
+
3140
+ // 2. 排序:从高到低
3141
+ totals.sort((a, b) => b.value - a.value)
3142
+
3143
+ // 3. 核心逻辑:处理 topN 和 “其他” 逻辑
3144
+ let chartData = []
3145
+ if (topN > 0 && totals.length > topN) {
3146
+ // 截取前 N 名
3147
+ chartData = totals.slice(0, topN)
3148
+ // 汇总剩余的为“其他”
3149
+ const othersValue = totals
3150
+ .slice(topN)
3151
+ .reduce((sum, item) => sum + item.value, 0)
3152
+ chartData.push({
3153
+ name: '其他',
3154
+ value: Number(othersValue.toFixed(2))
3155
+ })
3156
+ } else {
3157
+ // topN 为 0 时展示全部
3158
+ chartData = totals
3159
+ }
3160
+
3161
+ // 4. 自适应颜色生成
3162
+ const generateColors = (count) => {
3163
+ const presets = [
3164
+ '#5470c6',
3165
+ '#91cc75',
3166
+ '#fac858',
3167
+ '#ee6666',
3168
+ '#73c0de',
3169
+ '#3ba272',
3170
+ '#fc8452',
3171
+ '#9a60b4',
3172
+ '#ea7ccc'
3173
+ ]
3174
+ if (count <= presets.length) return presets.slice(0, count)
3175
+
3176
+ return chartData.map((_, i) => {
3177
+ if (i < presets.length) return presets[i]
3178
+ // 超过预设后,动态生成 HSL 颜色
3179
+ return `hsl(${(i * 137.5) % 360}, 60%, 65%)` // 使用黄金角度 137.5 确保颜色分布均匀
3180
+ })
3181
+ }
3182
+
3183
+ // 5. 调用现有绘图方法
3184
+ return drawPieWithTotal({
3185
+ el: 'authorTotalOvertimeRankSummary',
3186
+ title: '加班总时长排名分布',
3187
+ unit: '小时',
3188
+ totalLabel: '总时长',
3189
+ data: chartData,
3190
+ colors: generateColors(chartData.length)
3191
+ })
3192
+ }
3193
+
3194
+ // 渲染累计加班排名(chart 下方)
3195
+ function renderAuthorTotalOvertimeRankFromDs(ds, topN = 20) {
3196
+ const box = document.getElementById('authorTotalOvertimeRank')
3197
+ if (!box) return
3198
+
3199
+ if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) {
3200
+ box.innerHTML = '<div style="color:#777">暂无加班时长数据</div>'
3201
+ return
3202
+ }
3203
+
3204
+ // 1. 建立索引映射 (O(n) 性能优化)
3205
+ const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
3206
+
3207
+ const totals = ds.authors.map((author) => {
3208
+ const data = seriesMap.get(author)
3209
+ const total = Array.isArray(data)
3210
+ ? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
3211
+ : 0
3212
+ return { author, total }
3213
+ })
3214
+
3215
+ // 2. 排序
3216
+ totals.sort(
3217
+ (x, y) =>
3218
+ y.total - x.total || String(x.author).localeCompare(String(y.author))
3219
+ )
3220
+
3221
+ // 3. 处理 topN 为 0 输出全部的逻辑
3222
+ const top = topN > 0 ? totals.slice(0, topN) : totals
3223
+ const count = top.length
3224
+
3225
+ // 4. 动态生成颜色函数 (自适应任意长度)
3226
+ const getColor = (index, totalCount) => {
3227
+ const presetColors = [
3228
+ '#1976d2',
3229
+ '#00a76f',
3230
+ '#fb8c00',
3231
+ '#d32f2f',
3232
+ '#6a1b9a',
3233
+ '#00897b',
3234
+ '#ef5350',
3235
+ '#ffa000',
3236
+ '#5c6bc0',
3237
+ '#43a047'
3238
+ ]
3239
+
3240
+ if (index < presetColors.length && totalCount <= presetColors.length) {
3241
+ return presetColors[index]
3242
+ }
3243
+
3244
+ const hue = (index * (360 / totalCount) + 200) % 360
3245
+ return `hsl(${hue}, 65%, 50%)`
3246
+ }
3247
+
3248
+ const safeEscape = (str) =>
3249
+ typeof escapeHtml === 'function'
3250
+ ? escapeHtml(str)
3251
+ : String(str).replace(
3252
+ /[&<>"']/g,
3253
+ (m) =>
3254
+ ({
3255
+ '&': '&amp;',
3256
+ '<': '&lt;',
3257
+ '>': '&gt;',
3258
+ '"': '&quot;',
3259
+ "'": '&#39;'
3260
+ })[m]
3261
+ )
3262
+
3263
+ // 5. 计算统计区间(显示总体覆盖范围)
3264
+ let rangeStr = ''
3265
+ if (Array.isArray(ds.allPeriods) && ds.allPeriods.length) {
3266
+ const first = ds.allPeriods[0]
3267
+ const last = ds.allPeriods[ds.allPeriods.length - 1]
3268
+ const activeType =
3269
+ document.querySelector('#tabsTotalOvertime button.active')?.dataset
3270
+ .type || 'daily'
3271
+
3272
+ const periodToDate = (p, type) => {
3273
+ if (type === 'daily') return p
3274
+ if (type === 'weekly' && p.includes('-W')) {
3275
+ const [yy, ww] = p.split('-W')
3276
+ const r = getISOWeekRange(Number(yy), Number(ww))
3277
+ return `${r.start} ~ ${r.end}`
3278
+ }
3279
+ if (type === 'monthly') {
3280
+ const [y, m] = p.split('-')
3281
+ const start = `${y}-${m}-01`
3282
+ const end = new Date(Number(y), Number(m), 0)
3283
+ const endStr = formatDateYMD(end)
3284
+ return `${start} ~ ${endStr}`
3285
+ }
3286
+ if (type === 'yearly') return `${p}-01-01 ~ ${p}-12-31`
3287
+ return p
3288
+ }
3289
+
3290
+ try {
3291
+ const firstRange = periodToDate(first, activeType)
3292
+ const lastRange = periodToDate(last, activeType)
3293
+ // If daily, show start ~ end concisely
3294
+ if (activeType === 'daily')
3295
+ rangeStr = `统计区间:${firstRange} ~ ${lastRange}`
3296
+ else rangeStr = `统计区间:${firstRange} —— ${lastRange}`
3297
+ rangeStr = `<div style="color:#666;margin-bottom:6px;font-size:13px">${rangeStr}</div>`
3298
+ } catch (e) {
3299
+ rangeStr = ''
3300
+ }
3301
+ }
3302
+
3303
+ // 6. 奖牌与序号逻辑
3304
+ const medal = (i) =>
3305
+ i === 0 ? '🥇 ' : i === 1 ? '🥈 ' : i === 2 ? '🥉 ' : `${i + 1}. `
3306
+
3307
+ // Prefer ds.totals if available
3308
+ let totalsList = Array.isArray(ds.totals)
3309
+ ? ds.totals.map((t) => ({
3310
+ author: t.author,
3311
+ totalHours: t.totalHours,
3312
+ days: t.days,
3313
+ avg: t.avg
3314
+ }))
3315
+ : null
3316
+ if (!totalsList) {
3317
+ const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
3318
+ totalsList = ds.authors.map((author) => {
3319
+ const data = seriesMap.get(author)
3320
+ const total = Array.isArray(data)
3321
+ ? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
3322
+ : 0
3323
+ return { author, totalHours: Number(total.toFixed(2)), days: 0, avg: 0 }
3324
+ })
3325
+ }
3326
+
3327
+ // 7. 渲染页面(header + 列表),并显示占用天数 & 平均小时,title hover 提示
3328
+ const listHtml = count
3329
+ ? top
3330
+ .map((t, i) => {
3331
+ const info = totalsList.find((x) => x.author === t.author) || {}
3332
+ const total = Number((info.totalHours ?? t.total ?? 0).toFixed(2))
3333
+ const days = info.days ?? 0
3334
+ const avg =
3335
+ info.avg ?? (days > 0 ? Number((total / days).toFixed(2)) : 0)
3336
+ const title = `累计时长:${total} 小时\n占用天数:${days} 天\n平均每日:${avg} 小时`
3337
+ return `
3338
+ <div class="rank-item" title="${title}" style="display: flex; align-items: center; margin-bottom: 8px;">
3339
+ <span class="dot" style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; background:${getColor(i, count)}"></span>
3340
+ <span class="author" style="flex: 1;">
3341
+ ${medal(i)}${safeEscape(t.author)}
3342
+ <div style="font-size:12px;color:#666;margin-top:2px">占用天数: ${days} 天 · 平均: ${avg} 小时</div>
3343
+ </span>
3344
+ <span class="hours" style="font-weight: bold;">${Number(total).toFixed(2)} 小时</span>
3345
+ </div>
3346
+ `
3347
+ })
3348
+ .join('')
3349
+ : '<div style="color:#777">暂无加班时长数据</div>'
3350
+
3351
+ box.innerHTML = `${rangeStr}${listHtml}`
3352
+ }
3353
+
3354
+ // ====== 开发者 累计提交Changed(按日/周/月/年累计 Changed 行数) ======
3355
+ function buildAuthorTotalCommitsChangedDataset(commits, type) {
3356
+ const byAuthorPeriod = new Map()
3357
+ const periods = new Set()
3358
+
3359
+ commits.forEach((c) => {
3360
+ const author = c.author || 'unknown'
3361
+ const changed = Number(c.changed) || 0
3362
+ const d = new Date(c.date)
3363
+ if (Number.isNaN(d.valueOf())) return
3364
+
3365
+ let period
3366
+ if (type === 'daily') period = d.toISOString().slice(0, 10)
3367
+ else if (type === 'weekly') period = getIsoWeekKey(d.toISOString())
3368
+ else if (type === 'monthly')
3369
+ period = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
3370
+ else if (type === 'yearly') period = String(d.getFullYear())
3371
+ else period = d.toISOString().slice(0, 10)
3372
+
3373
+ periods.add(period)
3374
+ if (!byAuthorPeriod.has(author)) byAuthorPeriod.set(author, {})
3375
+ const obj = byAuthorPeriod.get(author)
3376
+ obj[period] = (obj[period] || 0) + changed
3377
+ })
3378
+
3379
+ const allPeriods = Array.from(periods).sort()
3380
+ const authors = Array.from(byAuthorPeriod.keys()).sort()
3381
+ const series = authors.map((a) => ({
3382
+ name: a,
3383
+ type: 'line',
3384
+ smooth: true,
3385
+ data: allPeriods.map((p) =>
3386
+ Number((byAuthorPeriod.get(a)[p] || 0).toFixed(2))
3387
+ )
3388
+ }))
3389
+
3390
+ const totals = authors.map((a) => {
3391
+ const periodObj = byAuthorPeriod.get(a) || {}
3392
+ const totalChanged = Object.values(periodObj).reduce(
3393
+ (s, v) => s + (Number(v) || 0),
3394
+ 0
3395
+ )
3396
+ const days = Object.keys(periodObj).length
3397
+ const avg = days > 0 ? Number((totalChanged / days).toFixed(2)) : 0
3398
+ return {
3399
+ author: a,
3400
+ totalChanged: Number(totalChanged.toFixed(2)),
3401
+ days,
3402
+ avg
3403
+ }
3404
+ })
3405
+
3406
+ return { authors, allPeriods, series, totals }
3407
+ }
3408
+
3409
+ function drawAuthorTotalCommitsChangedTrends(commits) {
3410
+ const el = document.getElementById('chartAuthorTotalCommitsChanged')
3411
+ if (!el) return null
3412
+ // FIXME: remove debug log before production
3413
+ console.log('❌', 'window.__config', window.__config)
3414
+ if (!window.__config.git.numstat) {
3415
+
3416
+ document.getElementById('totalCommitsChangedCard').style.display = 'none'
3417
+ document.getElementById('totalCommitsChangedRankSection').style.display = 'none'
3418
+ return null
3419
+ }
3420
+ const chart = echarts.init(el)
3421
+ chartInstances.push(chart)
3422
+
3423
+ function render(type) {
3424
+ const ds = buildAuthorTotalCommitsChangedDataset(commits, type)
3425
+ chart.setOption({
3426
+ tooltip: {
3427
+ trigger: 'axis',
3428
+ formatter(params) {
3429
+ if (!params || !params.length) return ''
3430
+ const label = params[0].axisValue
3431
+ const lines = params
3432
+ .filter((i) => i.data > 0)
3433
+ .sort((a, b) => (b.data || 0) - (a.data || 0))
3434
+ .map((item) => `${item.marker}${item.seriesName}: ${item.data} 行`)
3435
+ .join('<br/>')
3436
+ return `<div>${label}</div>${lines}`
3437
+ }
3438
+ },
3439
+ legend: { data: ds.authors },
3440
+ xAxis: { type: 'category', data: ds.allPeriods },
3441
+ yAxis: { type: 'value', name: 'Changed 行数' },
3442
+ series: ds.series
3443
+ })
3444
+
3445
+ try {
3446
+ renderAuthorTotalCommitsChangedRankFromDs(ds, 0)
3447
+ renderAuthorTotalCommitsChangedRank(ds, 0)
3448
+ } catch (e) {
3449
+ console.warn('更新累计提交Changed排名失败', e)
3450
+ }
3451
+ }
3452
+
3453
+ render('daily')
3454
+
3455
+ const tabs = document.querySelectorAll('#tabsTotalCommitsChanged button')
3456
+ tabs.forEach((btnEl) => {
3457
+ btnEl.addEventListener('click', () => {
3458
+ tabs.forEach((b) => b.classList.remove('active'))
3459
+ btnEl.classList.add('active')
3460
+ render(btnEl.dataset.type)
3461
+ })
3462
+ })
3463
+
3464
+ chart.on('click', (p) => {
3465
+ try {
3466
+ if (!p || p.componentType !== 'series') return
3467
+ const label = p.axisValue || p.name
3468
+ const author = p.seriesName
3469
+ if (!label || !author) return
3470
+ const type =
3471
+ document.querySelector('#tabsTotalCommitsChanged button.active')
3472
+ ?.dataset.type || 'daily'
3473
+
3474
+ const filteredCommits = commits.filter((c) => {
3475
+ const a = c.author || 'unknown'
3476
+ if (a !== author) return false
3477
+ const d = new Date(c.date)
3478
+ if (Number.isNaN(d.valueOf())) return false
3479
+
3480
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
3481
+ if (type === 'weekly') {
3482
+ if (!label.includes('-W')) return false
3483
+ const [yy, ww] = label.split('-W')
3484
+ const range = getISOWeekRange(Number(yy), Number(ww))
3485
+ const day = d.toISOString().slice(0, 10)
3486
+ return day >= range.start && day <= range.end
3487
+ }
3488
+ if (type === 'monthly') {
3489
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
3490
+ return month === label
3491
+ }
3492
+ const year = String(d.getFullYear())
3493
+ return year === label
3494
+ })
3495
+
3496
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
3497
+
3498
+ if (type === 'weekly') {
3499
+ const weeklyItem = {
3500
+ outsideWorkCount: filteredCommits.length,
3501
+ outsideWorkRate: 0
3502
+ }
3503
+ showSideBarForWeek({
3504
+ period: label,
3505
+ weeklyItem,
3506
+ commits: filteredCommits,
3507
+ titleDrawer: `${author} Changed 本周详情`
3508
+ })
3509
+ } else {
3510
+ showDayDetailSidebar({
3511
+ date: label,
3512
+ count: filteredCommits.length,
3513
+ commits: filteredCommits,
3514
+ titleDrawer: `${author} Changed ${type} 详情`
3515
+ })
3516
+ }
3517
+ } catch (err) {
3518
+ console.warn('Total commits changed chart click handler error', err)
3519
+ }
3520
+ })
3521
+
3522
+ return chart
3523
+ }
3524
+
3525
+ function renderAuthorTotalCommitsChangedRank(ds, topN = 10) {
3526
+ const el = document.getElementById('authorTotalCommitsChangedRankSummary')
3527
+
3528
+ if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) return
3529
+ const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
3530
+ const totals = ds.authors.map((author) => {
3531
+ const data = seriesMap.get(author)
3532
+ const total = Array.isArray(data)
3533
+ ? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
3534
+ : 0
3535
+ return { name: author, value: Number(total.toFixed(2)) }
3536
+ })
3537
+ totals.sort((a, b) => b.value - a.value)
3538
+
3539
+ let chartData = []
3540
+ if (topN > 0 && totals.length > topN) {
3541
+ chartData = totals.slice(0, topN)
3542
+ const othersValue = totals
3543
+ .slice(topN)
3544
+ .reduce((sum, item) => sum + item.value, 0)
3545
+ chartData.push({ name: '其他', value: Number(othersValue.toFixed(2)) })
3546
+ } else chartData = totals
3547
+
3548
+ // 4. 自适应颜色生成
3549
+ const generateColors = (count) => {
3550
+ const presets = [
3551
+ '#5470c6',
3552
+ '#91cc75',
3553
+ '#fac858',
3554
+ '#ee6666',
3555
+ '#73c0de',
3556
+ '#3ba272',
3557
+ '#fc8452',
3558
+ '#9a60b4',
3559
+ '#ea7ccc'
3560
+ ]
3561
+ if (count <= presets.length) return presets.slice(0, count)
3562
+
3563
+ return chartData.map((_, i) => {
3564
+ if (i < presets.length) return presets[i]
3565
+ // 超过预设后,动态生成 HSL 颜色
3566
+ return `hsl(${(i * 137.5) % 360}, 60%, 65%)` // 使用黄金角度 137.5 确保颜色分布均匀
3567
+ })
3568
+ }
3569
+
3570
+ return drawPieWithTotal({
3571
+ el,
3572
+ title: '提交Changed排名分布',
3573
+ unit: '行',
3574
+ totalLabel: '总行数',
3575
+ data: chartData,
3576
+ colors: generateColors(chartData.length)
3577
+ })
3578
+ }
3579
+
3580
+ function renderAuthorTotalCommitsChangedRankFromDs(ds, topN = 20) {
3581
+ const box = document.getElementById('authorTotalCommitsChangedRank')
3582
+ if (!box) return
3583
+ if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) {
3584
+ box.innerHTML = '<div style="color:#777">暂无提交Changed数据</div>'
3585
+ return
3586
+ }
3587
+
3588
+ const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
3589
+ const totals = ds.authors.map((author) => {
3590
+ const data = seriesMap.get(author) || []
3591
+ const total = Array.isArray(data)
3592
+ ? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
3593
+ : 0
3594
+ return { author, total }
3595
+ })
3596
+
3597
+ totals.sort(
3598
+ (x, y) =>
3599
+ y.total - x.total || String(x.author).localeCompare(String(y.author))
3600
+ )
3601
+
3602
+ const top = topN > 0 ? totals.slice(0, topN) : totals
3603
+ const count = top.length
3604
+
3605
+ const getColor = (index, totalCount) => {
3606
+ const presetColors = [
3607
+ '#1976d2',
3608
+ '#00a76f',
3609
+ '#fb8c00',
3610
+ '#d32f2f',
3611
+ '#6a1b9a',
3612
+ '#00897b',
3613
+ '#ef5350',
3614
+ '#ffa000',
3615
+ '#5c6bc0',
3616
+ '#43a047'
3617
+ ]
3618
+ if (index < presetColors.length && totalCount <= presetColors.length)
3619
+ return presetColors[index]
3620
+ const hue = (index * (360 / totalCount) + 200) % 360
3621
+ return `hsl(${hue}, 65%, 50%)`
3622
+ }
3623
+
3624
+ const safeEscape = (str) =>
3625
+ typeof escapeHtml === 'function' ? escapeHtml(str) : String(str)
3626
+
3627
+ let rangeStr = ''
3628
+ if (Array.isArray(ds.allPeriods) && ds.allPeriods.length) {
3629
+ const first = ds.allPeriods[0]
3630
+ const last = ds.allPeriods[ds.allPeriods.length - 1]
3631
+ const activeType =
3632
+ document.querySelector('#tabsTotalCommitsChanged button.active')?.dataset
3633
+ .type || 'daily'
3634
+
3635
+ const periodToDate = (p, type) => {
3636
+ if (type === 'daily') return p
3637
+ if (type === 'weekly' && p.includes('-W')) {
3638
+ const [yy, ww] = p.split('-W')
3639
+ const r = getISOWeekRange(Number(yy), Number(ww))
3640
+ return `${r.start} ~ ${r.end}`
3641
+ }
3642
+ if (type === 'monthly') {
3643
+ const [y, m] = p.split('-')
3644
+ const start = `${y}-${m}-01`
3645
+ const end = new Date(Number(y), Number(m), 0)
3646
+ const endStr = formatDateYMD(end)
3647
+ return `${start} ~ ${endStr}`
3648
+ }
3649
+ if (type === 'yearly') return `${p}-01-01 ~ ${p}-12-31`
3650
+ return p
3651
+ }
3652
+
3653
+ try {
3654
+ const firstRange = periodToDate(first, activeType)
3655
+ const lastRange = periodToDate(last, activeType)
3656
+ if (activeType === 'daily')
3657
+ rangeStr = `统计区间:${firstRange} ~ ${lastRange}`
3658
+ else rangeStr = `统计区间:${firstRange} —— ${lastRange}`
3659
+ rangeStr = `<div style="color:#666;margin-bottom:6px;font-size:13px">${rangeStr}</div>`
3660
+ } catch (e) {
3661
+ rangeStr = ''
3662
+ }
3663
+ }
3664
+
3665
+ const medal = (i) =>
3666
+ i === 0 ? '🥇 ' : i === 1 ? '🥈 ' : i === 2 ? '🥉 ' : `${i + 1}. `
3667
+
3668
+ let totalsList = Array.isArray(ds.totals)
3669
+ ? ds.totals.map((t) => ({
3670
+ author: t.author,
3671
+ totalChanged: t.totalChanged,
3672
+ days: t.days,
3673
+ avg: t.avg
3674
+ }))
3675
+ : null
3676
+ if (!totalsList) {
3677
+ totalsList = totals.map((t) => ({
3678
+ author: t.author,
3679
+ totalChanged: Number((t.total || 0).toFixed(2)),
3680
+ days: 0,
3681
+ avg: 0
3682
+ }))
3683
+ }
3684
+
3685
+ const listHtml = count
3686
+ ? top
3687
+ .map((t, i) => {
3688
+ const info = totalsList.find((x) => x.author === t.author) || {}
3689
+ const total = Number((info.totalChanged ?? t.total ?? 0).toFixed(2))
3690
+ const days = info.days ?? 0
3691
+ const avg =
3692
+ info.avg ?? (days > 0 ? Number((total / days).toFixed(2)) : 0)
3693
+ const title = `累计Changed:${total} 行\n占用天数:${days} 天\n平均每日:${avg} 行`
3694
+ return `
3695
+ <div class="rank-item" title="${title}" style="display: flex; align-items: center; margin-bottom: 8px;">
3696
+ <span class="dot" style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; background:${getColor(i, count)}"></span>
3697
+ <span class="author" style="flex: 1;">
3698
+ ${medal(i)}${safeEscape(t.author)}
3699
+ <div style="font-size:12px;color:#666;margin-top:2px">占用天数: ${days} 天 · 平均: ${avg} 行</div>
3700
+ </span>
3701
+ <span class="hours" style="font-weight: bold;">${Number(total).toFixed(2)} 行</span>
3702
+ </div>
3703
+ `
3704
+ })
3705
+ .join('')
3706
+ : '<div style="color:#777">暂无提交Changed数据</div>'
3707
+
3708
+ box.innerHTML = `${rangeStr}${listHtml}`
3709
+ }
3710
+
3711
+ // 渲染作者午休累计时长分布饼图
3712
+ function renderAuthorTotalLunchTimeRank(ds, topN = 10) {
3713
+ if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) return
3714
+ const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
3715
+ const totals = ds.authors.map((author) => {
3716
+ const data = seriesMap.get(author)
3717
+ const total = Array.isArray(data)
3718
+ ? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
3719
+ : 0
3720
+ return { name: author, value: Number(total.toFixed(2)) }
3721
+ })
3722
+
3723
+ // 4. 自适应颜色生成
3724
+ const generateColors = (count) => {
3725
+ const presets = [
3726
+ '#5470c6',
3727
+ '#91cc75',
3728
+ '#fac858',
3729
+ '#ee6666',
3730
+ '#73c0de',
3731
+ '#3ba272',
3732
+ '#fc8452',
3733
+ '#9a60b4',
3734
+ '#ea7ccc'
3735
+ ]
3736
+ if (count <= presets.length) return presets.slice(0, count)
3737
+
3738
+ return totals.map((_, i) => {
3739
+ if (i < presets.length) return presets[i]
3740
+ // 超过预设后,动态生成 HSL 颜色
3741
+ return `hsl(${(i * 137.5) % 360}, 60%, 65%)` // 使用黄金角度 137.5 确保颜色分布均匀
3742
+ })
3743
+ }
3744
+
3745
+ // TODO: remove debug log before production
3746
+ console.log('✅', 'totals', totals)
3747
+ return drawPieWithTotal({
3748
+ el: 'authorTotalLunchTimeRankSummary',
3749
+ title: '午休累计时长排名分布',
3750
+ unit: '小时',
3751
+ totalLabel: '总时长',
3752
+ data: totals,
3753
+ colors: generateColors(totals.length)
3754
+ })
3755
+ }
3756
+
3757
+ // 渲染午休累计排名(chart 下方),含序号/奖牌与统计区间
3758
+ function renderAuthorTotalLunchTimeRankFromDs(ds, topN = 20) {
3759
+ const box = document.getElementById('authorTotalLunchTimeRank')
3760
+ if (!box) return
3761
+
3762
+ if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) {
3763
+ box.innerHTML = '<div style="color:#777">暂无午休累计时长数据</div>'
3764
+ return
3765
+ }
3766
+
3767
+ // Prefer ds.totals (contains days/avg) if available, otherwise compute totals from series
3768
+ let totals = Array.isArray(ds.totals)
3769
+ ? ds.totals.map((t) => ({
3770
+ author: t.author,
3771
+ totalHours: t.totalHours,
3772
+ days: t.days,
3773
+ avg: t.avg
3774
+ }))
3775
+ : null
3776
+
3777
+ if (!totals) {
3778
+ const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
3779
+ totals = ds.authors.map((author) => {
3780
+ const data = seriesMap.get(author) || []
3781
+ const total = Array.isArray(data)
3782
+ ? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
3783
+ : 0
3784
+ return { author, totalHours: Number(total.toFixed(2)), days: 0, avg: 0 }
3785
+ })
3786
+ }
3787
+
3788
+ totals.sort(
3789
+ (x, y) =>
3790
+ (y.totalHours || y.total || 0) - (x.totalHours || x.total || 0) ||
3791
+ String(x.author).localeCompare(String(y.author))
3792
+ )
3793
+
3794
+ const top = topN > 0 ? totals.slice(0, topN) : totals
3795
+ const count = top.length
3796
+
3797
+ const getColor = (index, totalCount) => {
3798
+ const presetColors = [
3799
+ '#1976d2',
3800
+ '#00a76f',
3801
+ '#fb8c00',
3802
+ '#d32f2f',
3803
+ '#6a1b9a',
3804
+ '#00897b',
3805
+ '#ef5350',
3806
+ '#ffa000',
3807
+ '#5c6bc0',
3808
+ '#43a047'
3809
+ ]
3810
+ if (index < presetColors.length && totalCount <= presetColors.length)
3811
+ return presetColors[index]
3812
+ const hue = (index * (360 / totalCount) + 200) % 360
3813
+ return `hsl(${hue}, 65%, 50%)`
3814
+ }
3815
+
3816
+ const safeEscape = (str) =>
3817
+ typeof escapeHtml === 'function'
3818
+ ? escapeHtml(str)
3819
+ : String(str).replace(
3820
+ /[&<>"']/g,
3821
+ (m) =>
3822
+ ({
3823
+ '&': '&amp;',
3824
+ '<': '&lt;',
3825
+ '>': '&gt;',
3826
+ '"': '&quot;',
3827
+ "'": '&#39;'
3828
+ })[m]
3829
+ )
3830
+
3831
+ let rangeStr = ''
3832
+ if (Array.isArray(ds.allPeriods) && ds.allPeriods.length) {
3833
+ const first = ds.allPeriods[0]
3834
+ const last = ds.allPeriods[ds.allPeriods.length - 1]
3835
+ const activeType =
3836
+ document.querySelector('#tabsTotalLunchTime button.active')?.dataset
3837
+ .type || 'daily'
3838
+
3839
+ const periodToDate = (p, type) => {
3840
+ if (type === 'daily') return p
3841
+ if (type === 'weekly' && p.includes('-W')) {
3842
+ const [yy, ww] = p.split('-W')
3843
+ const r = getISOWeekRange(Number(yy), Number(ww))
3844
+ return `${r.start} ~ ${r.end}`
3845
+ }
3846
+ if (type === 'monthly') {
3847
+ const [y, m] = p.split('-')
3848
+ const start = `${y}-${m}-01`
3849
+ const end = new Date(Number(y), Number(m), 0)
3850
+ const endStr = formatDateYMD(end)
3851
+ return `${start} ~ ${endStr}`
3852
+ }
3853
+ if (type === 'yearly') return `${p}-01-01 ~ ${p}-12-31`
3854
+ return p
3855
+ }
3856
+
3857
+ try {
3858
+ const firstRange = periodToDate(first, activeType)
3859
+ const lastRange = periodToDate(last, activeType)
3860
+ if (activeType === 'daily')
3861
+ rangeStr = `统计区间:${firstRange} ~ ${lastRange}`
3862
+ else rangeStr = `统计区间:${firstRange} —— ${lastRange}`
3863
+ rangeStr = `<div style="color:#666;margin-bottom:6px;font-size:13px">${rangeStr}</div>`
3864
+ } catch (e) {
3865
+ rangeStr = ''
3866
+ }
3867
+ }
3868
+
3869
+ const medal = (i) =>
3870
+ i === 0 ? '🥇 ' : i === 1 ? '🥈 ' : i === 2 ? '🥉 ' : `${i + 1}. `
3871
+
3872
+ const listHtml = count
3873
+ ? top
3874
+ .map((t, i) => {
3875
+ const total = Number((t.totalHours ?? t.total ?? 0).toFixed(2))
3876
+ const days = t.days ?? 0
3877
+ const avg =
3878
+ t.avg ?? (days > 0 ? Number((total / days).toFixed(2)) : 0)
3879
+ const title = `累计时长:${total} 小时\n占用天数:${days} 天\n平均每日:${avg} 小时`
3880
+ return `
3881
+ <div class="rank-item" title="${title}" style="display: flex; align-items: center; margin-bottom: 8px;">
3882
+ <span class="dot" style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; background:${getColor(i, count)}"></span>
3883
+ <span class="author" style="flex: 1;">
3884
+ ${medal(i)}${safeEscape(t.author)}
3885
+ <div style="font-size:12px;color:#666;margin-top:2px">占用天数: ${days} 天 · 平均: ${avg} 小时</div>
3886
+ </span>
3887
+ <span class="hours" style="font-weight: bold;">${Number(total).toFixed(2)} 小时</span>
3888
+ </div>
3889
+ `
3890
+ })
3891
+ .join('')
3892
+ : '<div style="color:#777">暂无午休累计时长数据</div>'
3893
+
3894
+ box.innerHTML = `${rangeStr}${listHtml}`
3895
+ }
3896
+
3897
+ // ====== 开发者 累计提交次数(按日/周/月/年累计提交数) ======
3898
+ function buildAuthorTotalCommitsDataset(commits, type) {
3899
+ const byAuthorPeriod = new Map()
3900
+ const periods = new Set()
3901
+
3902
+ commits.forEach((c) => {
3903
+ const author = c.author || 'unknown'
3904
+ const d = new Date(c.date)
3905
+ if (Number.isNaN(d.valueOf())) return
3906
+
3907
+ let period
3908
+ if (type === 'daily') period = d.toISOString().slice(0, 10)
3909
+ else if (type === 'weekly') period = getIsoWeekKey(d.toISOString())
3910
+ else if (type === 'monthly')
3911
+ period = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
3912
+ else if (type === 'yearly') period = String(d.getFullYear())
3913
+ else period = d.toISOString().slice(0, 10)
3914
+
3915
+ periods.add(period)
3916
+ if (!byAuthorPeriod.has(author)) byAuthorPeriod.set(author, {})
3917
+ const obj = byAuthorPeriod.get(author)
3918
+ obj[period] = (obj[period] || 0) + 1
3919
+ })
3920
+
3921
+ const allPeriods = Array.from(periods).sort()
3922
+ const authors = Array.from(byAuthorPeriod.keys()).sort()
3923
+ const series = authors.map((a) => ({
3924
+ name: a,
3925
+ type: 'line',
3926
+ smooth: true,
3927
+ data: allPeriods.map((p) => byAuthorPeriod.get(a)[p] || 0)
3928
+ }))
3929
+
3930
+ const totals = authors.map((a) => {
3931
+ const periodObj = byAuthorPeriod.get(a) || {}
3932
+ const totalCommits = Object.values(periodObj).reduce(
3933
+ (s, v) => s + (Number(v) || 0),
3934
+ 0
3935
+ )
3936
+ const days = Object.keys(periodObj).length
3937
+ const avg = days > 0 ? Number((totalCommits / days).toFixed(2)) : 0
3938
+ return { author: a, totalCommits, days, avg }
3939
+ })
3940
+
3941
+ return { authors, allPeriods, series, totals }
3942
+ }
3943
+
3944
+ function drawAuthorTotalCommitsTrends(commits) {
3945
+ const el = document.getElementById('chartAuthorTotalCommits')
3946
+ if (!el) return null
3947
+ const chart = echarts.init(el)
3948
+ chartInstances.push(chart)
3949
+
3950
+ function render(type) {
3951
+ const ds = buildAuthorTotalCommitsDataset(commits, type)
3952
+
3953
+ chart.setOption({
3954
+ tooltip: {
3955
+ trigger: 'axis',
3956
+ formatter(params) {
3957
+ if (!params || !params.length) return ''
3958
+ const label = params[0].axisValue
3959
+ const lines = params
3960
+ .filter((i) => i.data > 0)
3961
+ .sort((a, b) => (b.data || 0) - (a.data || 0))
3962
+ .map((item) => `${item.marker}${item.seriesName}: ${item.data} 次`)
3963
+ .join('<br/>')
3964
+ return `<div>${label}</div>${lines}`
3965
+ }
3966
+ },
3967
+ legend: { data: ds.authors },
3968
+ xAxis: { type: 'category', data: ds.allPeriods },
3969
+ yAxis: { type: 'value', name: '提交次数' },
3970
+ series: ds.series
3971
+ })
3972
+
3973
+ try {
3974
+ renderAuthorTotalCommitsRankFromDs(ds, 0)
3975
+ renderAuthorTotalCommitsRank(ds, 0)
3976
+ } catch (e) {
3977
+ console.warn('更新累计提交排名失败', e)
3978
+ }
3979
+ }
3980
+
3981
+ render('daily')
3982
+
3983
+ const tabs = document.querySelectorAll('#tabsTotalCommits button')
3984
+ tabs.forEach((btnEl) => {
3985
+ btnEl.addEventListener('click', () => {
3986
+ tabs.forEach((b) => b.classList.remove('active'))
3987
+ btnEl.classList.add('active')
3988
+ render(btnEl.dataset.type)
3989
+ })
3990
+ })
3991
+
3992
+ chart.on('click', (p) => {
3993
+ try {
3994
+ if (!p || p.componentType !== 'series') return
3995
+ const label = p.axisValue || p.name
3996
+ const author = p.seriesName
3997
+ if (!label || !author) return
3998
+ const type =
3999
+ document.querySelector('#tabsTotalCommits button.active')?.dataset
4000
+ .type || 'daily'
4001
+
4002
+ const filteredCommits = commits.filter((c) => {
4003
+ const a = c.author || 'unknown'
4004
+ if (a !== author) return false
4005
+ const d = new Date(c.date)
4006
+ if (Number.isNaN(d.valueOf())) return false
4007
+
4008
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
4009
+ if (type === 'weekly') {
4010
+ if (!label.includes('-W')) return false
4011
+ const [yy, ww] = label.split('-W')
4012
+ const range = getISOWeekRange(Number(yy), Number(ww))
4013
+ const day = d.toISOString().slice(0, 10)
4014
+ return day >= range.start && day <= range.end
4015
+ }
4016
+ if (type === 'monthly') {
4017
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
4018
+ return month === label
4019
+ }
4020
+ const year = String(d.getFullYear())
4021
+ return year === label
4022
+ })
4023
+
4024
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
4025
+
4026
+ if (type === 'weekly') {
4027
+ const weeklyItem = {
4028
+ outsideWorkCount: filteredCommits.length,
4029
+ outsideWorkRate: 0
4030
+ }
4031
+ showSideBarForWeek({
4032
+ period: label,
4033
+ weeklyItem,
4034
+ commits: filteredCommits,
4035
+ titleDrawer: `${author} 提交 本周详情`
4036
+ })
4037
+ } else {
4038
+ showDayDetailSidebar({
4039
+ date: label,
4040
+ count: filteredCommits.length,
4041
+ commits: filteredCommits,
4042
+ titleDrawer: `${author} 提交 ${type} 详情`
4043
+ })
4044
+ }
4045
+ } catch (err) {
4046
+ console.warn('Total commits chart click handler error', err)
4047
+ }
4048
+ })
4049
+
4050
+ return chart
4051
+ }
4052
+
4053
+ function renderAuthorTotalCommitsRank(ds, topN = 10) {
4054
+ if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) return
4055
+ const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
4056
+ const totals = ds.authors.map((author) => {
4057
+ const data = seriesMap.get(author)
4058
+ const total = Array.isArray(data)
4059
+ ? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
4060
+ : 0
4061
+ return { name: author, value: Number(total.toFixed(0)) }
4062
+ })
4063
+ totals.sort((a, b) => b.value - a.value)
4064
+
4065
+ let chartData = []
4066
+ if (topN > 0 && totals.length > topN) {
4067
+ chartData = totals.slice(0, topN)
4068
+ const othersValue = totals
4069
+ .slice(topN)
4070
+ .reduce((sum, item) => sum + item.value, 0)
4071
+ chartData.push({ name: '其他', value: Number(othersValue.toFixed(0)) })
4072
+ } else chartData = totals
4073
+
4074
+ // 4. 自适应颜色生成
4075
+ const generateColors = (count) => {
4076
+ const presets = [
4077
+ '#5470c6',
4078
+ '#91cc75',
4079
+ '#fac858',
4080
+ '#ee6666',
4081
+ '#73c0de',
4082
+ '#3ba272',
4083
+ '#fc8452',
4084
+ '#9a60b4',
4085
+ '#ea7ccc'
4086
+ ]
4087
+ if (count <= presets.length) return presets.slice(0, count)
4088
+
4089
+ return chartData.map((_, i) => {
4090
+ if (i < presets.length) return presets[i]
4091
+ // 超过预设后,动态生成 HSL 颜色
4092
+ return `hsl(${(i * 137.5) % 360}, 60%, 65%)` // 使用黄金角度 137.5 确保颜色分布均匀
4093
+ })
4094
+ }
4095
+
4096
+ // FIXME: remove debug log before production
4097
+ console.log('❌', 'chartData', chartData)
4098
+ return drawPieWithTotal({
4099
+ el: 'authorTotalCommitsRankSummary',
4100
+ title: '提交次数排名分布',
4101
+ unit: '次',
4102
+ totalLabel: '总提交',
4103
+ data: chartData,
4104
+ colors: generateColors(totals.length)
4105
+ })
4106
+ }
4107
+
4108
+ function renderAuthorTotalCommitsRankFromDs(ds, topN = 20) {
4109
+ const box = document.getElementById('authorTotalCommitsRank')
4110
+ if (!box) return
4111
+ if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) {
4112
+ box.innerHTML = '<div style="color:#777">暂无提交次数数据</div>'
4113
+ return
4114
+ }
4115
+
4116
+ const seriesMap = new Map(ds.series.map((s) => [s.name, s.data]))
4117
+ const totals = ds.authors.map((author) => {
4118
+ const data = seriesMap.get(author) || []
4119
+ const total = Array.isArray(data)
4120
+ ? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
4121
+ : 0
4122
+ return { author, total }
4123
+ })
4124
+
4125
+ totals.sort(
4126
+ (x, y) =>
4127
+ y.total - x.total || String(x.author).localeCompare(String(y.author))
4128
+ )
4129
+
4130
+ const top = topN > 0 ? totals.slice(0, topN) : totals
4131
+ const count = top.length
4132
+
4133
+ const getColor = (index, totalCount) => {
4134
+ const presetColors = [
4135
+ '#1976d2',
4136
+ '#00a76f',
4137
+ '#fb8c00',
4138
+ '#d32f2f',
4139
+ '#6a1b9a',
4140
+ '#00897b',
4141
+ '#ef5350',
4142
+ '#ffa000',
4143
+ '#5c6bc0',
4144
+ '#43a047'
4145
+ ]
4146
+ if (index < presetColors.length && totalCount <= presetColors.length)
4147
+ return presetColors[index]
4148
+ const hue = (index * (360 / totalCount) + 200) % 360
4149
+ return `hsl(${hue}, 65%, 50%)`
4150
+ }
4151
+
4152
+ const safeEscape = (str) =>
4153
+ typeof escapeHtml === 'function' ? escapeHtml(str) : String(str)
4154
+
4155
+ let rangeStr = ''
4156
+ if (Array.isArray(ds.allPeriods) && ds.allPeriods.length) {
4157
+ const first = ds.allPeriods[0]
4158
+ const last = ds.allPeriods[ds.allPeriods.length - 1]
4159
+ const activeType =
4160
+ document.querySelector('#tabsTotalCommits button.active')?.dataset.type ||
4161
+ 'daily'
4162
+
4163
+ const periodToDate = (p, type) => {
4164
+ if (type === 'daily') return p
4165
+ if (type === 'weekly' && p.includes('-W')) {
4166
+ const [yy, ww] = p.split('-W')
4167
+ const r = getISOWeekRange(Number(yy), Number(ww))
4168
+ return `${r.start} ~ ${r.end}`
4169
+ }
4170
+ if (type === 'monthly') {
4171
+ const [y, m] = p.split('-')
4172
+ const start = `${y}-${m}-01`
4173
+ const end = new Date(Number(y), Number(m), 0)
4174
+ const endStr = formatDateYMD(end)
4175
+ return `${start} ~ ${endStr}`
4176
+ }
4177
+ if (type === 'yearly') return `${p}-01-01 ~ ${p}-12-31`
4178
+ return p
4179
+ }
4180
+
4181
+ try {
4182
+ const firstRange = periodToDate(first, activeType)
4183
+ const lastRange = periodToDate(last, activeType)
4184
+ if (activeType === 'daily')
4185
+ rangeStr = `统计区间:${firstRange} ~ ${lastRange}`
4186
+ else rangeStr = `统计区间:${firstRange} —— ${lastRange}`
4187
+ rangeStr = `<div style="color:#666;margin-bottom:6px;font-size:13px">${rangeStr}</div>`
4188
+ } catch (e) {
4189
+ rangeStr = ''
4190
+ }
4191
+ }
4192
+
4193
+ const medal = (i) =>
4194
+ i === 0 ? '🥇 ' : i === 1 ? '🥈 ' : i === 2 ? '🥉 ' : `${i + 1}. `
4195
+
4196
+ let totalsList = Array.isArray(ds.totals)
4197
+ ? ds.totals.map((t) => ({
4198
+ author: t.author,
4199
+ totalCommits: t.totalCommits,
4200
+ days: t.days,
4201
+ avg: t.avg
4202
+ }))
4203
+ : null
4204
+ if (!totalsList) {
4205
+ totalsList = totals.map((t) => ({
4206
+ author: t.author,
4207
+ totalCommits: Number((t.total || 0).toFixed(0)),
4208
+ days: 0,
4209
+ avg: 0
4210
+ }))
4211
+ }
4212
+
4213
+ const listHtml = count
4214
+ ? top
4215
+ .map((t, i) => {
4216
+ const info = totalsList.find((x) => x.author === t.author) || {}
4217
+ const total = Number((info.totalCommits ?? t.total ?? 0).toFixed(0))
4218
+ const days = info.days ?? 0
4219
+ const avg =
4220
+ info.avg ?? (days > 0 ? Number((total / days).toFixed(2)) : 0)
4221
+ const title = `累计提交:${total} 次\n占用天数:${days} 天\n平均每日:${avg} 次`
4222
+ return `
4223
+ <div class="rank-item" title="${title}" style="display: flex; align-items: center; margin-bottom: 8px;">
4224
+ <span class="dot" style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; background:${getColor(i, count)}"></span>
4225
+ <span class="author" style="flex: 1;">
4226
+ ${medal(i)}${safeEscape(t.author)}
4227
+ <div style="font-size:12px;color:#666;margin-top:2px">占用天数: ${days} 天 · 平均: ${avg} 次</div>
4228
+ </span>
4229
+ <span class="hours" style="font-weight: bold;">${Number(total).toFixed(0)} 次</span>
4230
+ </div>
4231
+ `
4232
+ })
4233
+ .join('')
4234
+ : '<div style="color:#777">暂无提交次数数据</div>'
4235
+
4236
+ box.innerHTML = `${rangeStr}${listHtml}`
4237
+ }
4238
+
4239
+ // ========= 开发者 午休最晚提交(小时) =========
4240
+ function buildAuthorLunchDataset(
4241
+ commits,
4242
+ type,
4243
+ lunchStart = 12,
4244
+ lunchEnd = 14
4245
+ ) {
4246
+ const byAuthor = new Map()
4247
+ const periods = new Set()
4248
+
4249
+ commits.forEach((c) => {
4250
+ const d = new Date(c.date)
4251
+ if (Number.isNaN(d.valueOf())) return
4252
+ const h = d.getHours()
4253
+ const m = d.getMinutes()
4254
+ // 只考虑午休时间段内的提交
4255
+ if (!(h >= lunchStart && h < lunchEnd)) return
4256
+
4257
+ let key
4258
+ if (type === 'daily') key = d.toISOString().slice(0, 10)
4259
+ else if (type === 'weekly')
4260
+ key = getIsoWeekKey(d.toISOString().slice(0, 10))
4261
+ else key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
4262
+ if (!key) return
4263
+ periods.add(key)
4264
+
4265
+ const author = c.author || 'unknown'
4266
+ if (!byAuthor.has(author)) byAuthor.set(author, {})
4267
+ const obj = byAuthor.get(author)
4268
+ // 以小时小数表示提交时间(例如 12.5 表示 12:30),后者越大表示越靠近午休结束
4269
+ const hourDecimal = h + m / 60
4270
+ obj[key] = Math.max(obj[key] || 0, hourDecimal)
4271
+ })
4272
+
4273
+ const allPeriods = Array.from(periods).sort()
4274
+ const authors = Array.from(byAuthor.keys()).sort()
4275
+ const series = authors.map((a) => ({
4276
+ name: a,
4277
+ type: 'line',
4278
+ smooth: true,
4279
+ data: allPeriods.map((p) => byAuthor.get(a)[p] || 0)
4280
+ }))
4281
+ return { authors, allPeriods, series }
4282
+ }
4283
+
4284
+ function formatHourDecimal(h) {
4285
+ if (h == null || h === 0) return '-'
4286
+ const hh = Math.floor(h)
4287
+ const mm = Math.round((h - hh) * 60)
4288
+ return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`
4289
+ }
4290
+
4291
+ function drawAuthorLunchTrends(commits, stats) {
4292
+ const el = document.getElementById('chartAuthorLunch')
4293
+ if (!el) return null
4294
+ const chart = echarts.init(el)
4295
+ chartInstances.push(chart)
4296
+
4297
+ const lunchStart =
4298
+ typeof stats.lunchStart === 'number'
4299
+ ? stats.lunchStart
4300
+ : (window.__lunchStart ?? 12)
4301
+ const lunchEnd =
4302
+ typeof stats.lunchEnd === 'number'
4303
+ ? stats.lunchEnd
4304
+ : (window.__lunchEnd ?? 14)
4305
+
4306
+ function render(type) {
4307
+ const ds = buildAuthorLunchDataset(commits, type, lunchStart, lunchEnd)
4308
+ ds.rangeMap = {}
4309
+ for (const period of ds.allPeriods) {
4310
+ if (period.includes('-W')) {
4311
+ const [yy, ww] = period.split('-W')
4312
+ ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
4313
+ }
4314
+ }
4315
+
4316
+ chart.setOption({
4317
+ tooltip: {
4318
+ trigger: 'axis',
4319
+ formatter(params) {
4320
+ if (!params || !params.length) return ''
4321
+ const label = params[0].axisValue
4322
+ const isWeekly = type === 'weekly'
4323
+
4324
+ let extra = ''
4325
+ if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
4326
+ const { start, end } = ds.rangeMap[label]
4327
+ extra = `<div style="margin-top:4px;color:#999;font-size:12px">周区间:${start} ~ ${end}</div>`
4328
+ }
4329
+
4330
+ const lines = params
4331
+ .filter((i) => i.data > 0)
4332
+ .sort(
4333
+ (a, b) =>
4334
+ (b.data || 0) - (a.data || 0) ||
4335
+ String(a.seriesName).localeCompare(String(b.seriesName))
4336
+ )
4337
+ .map(
4338
+ (item) =>
4339
+ `${item.marker}${item.seriesName}: ${formatHourDecimal(item.data)}`
4340
+ )
4341
+ .join('<br/>')
4342
+
4343
+ return `<div>${label}</div>${extra}${lines}`
4344
+ }
4345
+ },
4346
+ legend: { data: ds.authors },
4347
+ xAxis: { type: 'category', data: ds.allPeriods },
4348
+ yAxis: {
4349
+ type: 'value',
4350
+ name: '时间(小时)',
4351
+ min: lunchStart,
4352
+ max: lunchEnd
4353
+ },
4354
+ series: ds.series
4355
+ })
4356
+ }
4357
+
4358
+ render('daily')
4359
+
4360
+ const tabs = document.querySelectorAll('#tabsLunch button')
4361
+ tabs.forEach((btnEl) => {
4362
+ btnEl.addEventListener('click', () => {
4363
+ tabs.forEach((b) => b.classList.remove('active'))
4364
+ btnEl.classList.add('active')
4365
+ render(btnEl.dataset.type)
4366
+ })
4367
+ })
4368
+
4369
+ renderLunchWeeklyRankSummary(commits, { lunchStart, lunchEnd })
4370
+ renderLunchWeeklyRiskSummary(commits, { lunchStart, lunchEnd })
4371
+ renderLunchMonthlyRankSummary(commits, { lunchStart, lunchEnd })
4372
+ renderLunchMonthlyRiskSummary(commits, { lunchStart, lunchEnd })
4373
+
4374
+ // 点击事件:点击某个数据点(作者+周期)打开侧栏,展示该作者在该周期午休时间段内的提交明细
4375
+ chart.on('click', (p) => {
4376
+ try {
4377
+ if (!p || p.componentType !== 'series') return
4378
+ const label = p.axisValue || p.name
4379
+ const author = p.seriesName
4380
+ if (!label || !author) return
4381
+
4382
+ // 识别当前 tabs 类型(daily|weekly|monthly)
4383
+ const type =
4384
+ document.querySelector('#tabsLunch button.active')?.dataset.type ||
4385
+ 'daily'
4386
+
4387
+ // 过滤 commits:作者匹配 + 在午休时间段内 + 在所选周期内
4388
+ const filteredCommits = commits.filter((c) => {
4389
+ const a = c.author || 'unknown'
4390
+ if (a !== author) return false
4391
+ const d = new Date(c.date)
4392
+ if (Number.isNaN(d.valueOf())) return false
4393
+ const h = d.getHours()
4394
+ const m = d.getMinutes()
4395
+ if (!(h >= lunchStart && h < lunchEnd)) return false
4396
+
4397
+ if (type === 'daily') {
4398
+ return d.toISOString().slice(0, 10) === label
4399
+ }
4400
+ if (type === 'weekly') {
4401
+ // label 格式 YYYY-Www
4402
+ if (!label.includes('-W')) return false
4403
+ const [yy, ww] = label.split('-W')
4404
+ const range = getISOWeekRange(Number(yy), Number(ww))
4405
+ const day = d.toISOString().slice(0, 10)
4406
+ return day >= range.start && day <= range.end
4407
+ }
4408
+ // monthly
4409
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
4410
+ return month === label
4411
+ })
4412
+
4413
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
4414
+
4415
+ if (type === 'weekly') {
4416
+ const weeklyItem = {
4417
+ outsideWorkCount: filteredCommits.length,
4418
+ outsideWorkRate: 0
4419
+ }
4420
+ showSideBarForWeek({
4421
+ period: label,
4422
+ weeklyItem,
4423
+ commits: filteredCommits,
4424
+ titleDrawer: `${author} 午休本周提交详情`
4425
+ })
4426
+ } else {
4427
+ showDayDetailSidebar({
4428
+ date: label,
4429
+ count: filteredCommits.length,
4430
+ commits: filteredCommits,
4431
+ titleDrawer: `${author} 午休 ${type} 提交`
4432
+ })
4433
+ }
4434
+ } catch (err) {
4435
+ console.warn('Lunch chart click handler error', err)
4436
+ }
4437
+ })
4438
+
4439
+ return chart
4440
+ }
4441
+
4442
+ // ========== 开发者 累计午休时长(按日/周/月/年累计每日午休占用时长求和) =========
4443
+ function buildAuthorTotalLunchTimeDataset(
4444
+ commits,
4445
+ type,
4446
+ lunchStart = 12,
4447
+ lunchEnd = 14
4448
+ ) {
4449
+ // 1. 先统计每位作者每天午休期间的最后一次提交时间(小时小数)
4450
+ const byAuthorDay = new Map()
4451
+ commits.forEach((c) => {
4452
+ const d = new Date(c.date)
4453
+ if (Number.isNaN(d.valueOf())) return
4454
+ const h = d.getHours()
4455
+ const m = d.getMinutes()
4456
+ const hourDecimal = h + m / 60
4457
+ // 仅关注午休时间段内的提交
4458
+ if (!(hourDecimal >= lunchStart && hourDecimal < lunchEnd)) return
4459
+
4460
+ const author = c.author || 'unknown'
4461
+ const day = d.toISOString().slice(0, 10)
4462
+ if (!byAuthorDay.has(author)) byAuthorDay.set(author, new Map())
4463
+ const dayMap = byAuthorDay.get(author)
4464
+ const cur = dayMap.get(day) || 0
4465
+ dayMap.set(day, Math.max(cur, hourDecimal))
4466
+ })
4467
+
4468
+ // 2. 按周期(type)聚合,统计每一周期内的累计午休占用时长
4469
+ const byAuthorPeriod = new Map()
4470
+ const periods = new Set()
4471
+
4472
+ byAuthorDay.forEach((dayMap, author) => {
4473
+ for (const [day, lastHour] of dayMap.entries()) {
4474
+ let period
4475
+ if (type === 'daily') period = day
4476
+ else if (type === 'weekly') period = getIsoWeekKey(day)
4477
+ else if (type === 'monthly') period = day.slice(0, 7)
4478
+ else if (type === 'yearly') period = day.slice(0, 4)
4479
+ else period = day
4480
+ if (!period) continue
4481
+ periods.add(period)
4482
+ if (!byAuthorPeriod.has(author)) byAuthorPeriod.set(author, {})
4483
+ const obj = byAuthorPeriod.get(author)
4484
+ // 当天占用午休时长 = max(0, min(lastHour, lunchEnd) - lunchStart)
4485
+ const capped = Math.min(lastHour, lunchEnd)
4486
+ const duration = Math.max(0, capped - lunchStart)
4487
+ obj[period] = (obj[period] || 0) + duration
4488
+ }
4489
+ })
4490
+
4491
+ const allPeriods = Array.from(periods).sort()
4492
+ const authors = Array.from(byAuthorPeriod.keys()).sort()
4493
+ const series = authors.map((a) => ({
4494
+ name: a,
4495
+ type: 'line',
4496
+ smooth: true,
4497
+ data: allPeriods.map((p) =>
4498
+ Number((byAuthorPeriod.get(a)[p] || 0).toFixed(2))
4499
+ )
4500
+ }))
4501
+
4502
+ // 3. 计算每个作者的总时长/占用天数/日均占用,方便列表与 tooltip 使用
4503
+ const totals = authors.map((a) => {
4504
+ const periodObj = byAuthorPeriod.get(a) || {}
4505
+ const totalHours = Object.values(periodObj).reduce(
4506
+ (s, v) => s + (Number(v) || 0),
4507
+ 0
4508
+ )
4509
+ const days = byAuthorDay.get(a) ? byAuthorDay.get(a).size : 0
4510
+ const avg = days > 0 ? Number((totalHours / days).toFixed(2)) : 0
4511
+ return { author: a, totalHours: Number(totalHours.toFixed(2)), days, avg }
4512
+ })
4513
+
4514
+ return { authors, allPeriods, series, totals }
4515
+ }
4516
+
4517
+ function drawAuthorTotalLunchTimeTrends(commits, stats) {
4518
+ const el = document.getElementById('chartAuthorTotalLunchTime')
4519
+ if (!el) return null
4520
+ const chart = echarts.init(el)
4521
+ chartInstances.push(chart)
4522
+
4523
+ const lunchStart =
4524
+ typeof stats.lunchStart === 'number'
4525
+ ? stats.lunchStart
4526
+ : (window.__lunchStart ?? 12)
4527
+ const lunchEnd =
4528
+ typeof stats.lunchEnd === 'number'
4529
+ ? stats.lunchEnd
4530
+ : (window.__lunchEnd ?? 14)
4531
+
4532
+ function render(type) {
4533
+ const ds = buildAuthorTotalLunchTimeDataset(
4534
+ commits,
4535
+ type,
4536
+ lunchStart,
4537
+ lunchEnd
4538
+ )
4539
+ ds.rangeMap = {}
4540
+ for (const period of ds.allPeriods) {
4541
+ if (period.includes('-W')) {
4542
+ const [yy, ww] = period.split('-W')
4543
+ ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
4544
+ }
4545
+ }
4546
+
4547
+ chart.setOption({
4548
+ tooltip: {
4549
+ trigger: 'axis',
4550
+ formatter(params) {
4551
+ if (!params || !params.length) return ''
4552
+ const label = params[0].axisValue
4553
+ const isWeekly = type === 'weekly'
4554
+
4555
+ let extra = ''
4556
+ if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
4557
+ const { start, end } = ds.rangeMap[label]
4558
+ extra = `<div style="margin-top:4px;color:#999;font-size:12px">周区间:${start} ~ ${end}</div>`
4559
+ }
4560
+
4561
+ const lines = params
4562
+ .filter((i) => i.data > 0)
4563
+ .sort(
4564
+ (a, b) =>
4565
+ (b.data || 0) - (a.data || 0) ||
4566
+ String(a.seriesName).localeCompare(String(b.seriesName))
4567
+ )
4568
+ .map(
4569
+ (item) =>
4570
+ `${item.marker}${item.seriesName}: ${Number(item.data).toFixed(2)} 小时`
4571
+ )
4572
+ .join('<br/>')
4573
+
4574
+ return `<div>${label}</div>${extra}${lines}`
4575
+ }
4576
+ },
4577
+ legend: { data: ds.authors },
4578
+ xAxis: { type: 'category', data: ds.allPeriods },
4579
+ yAxis: { type: 'value', name: '累计午休工作时长 (小时)', min: 0 },
4580
+ series: ds.series
4581
+ })
4582
+
4583
+ // 更新排名与饼图
4584
+ try {
4585
+ renderAuthorTotalLunchTimeRankFromDs(ds, 0)
4586
+ renderAuthorTotalLunchTimeRank(ds, 0)
4587
+ } catch (e) {
4588
+ console.warn('更新累计午休排名失败', e)
4589
+ }
4590
+ }
4591
+
4592
+ render('daily')
4593
+
4594
+ const tabs = document.querySelectorAll('#tabsTotalLunchTime button')
4595
+ tabs.forEach((btnEl) => {
4596
+ btnEl.addEventListener('click', () => {
4597
+ tabs.forEach((b) => b.classList.remove('active'))
4598
+ btnEl.classList.add('active')
4599
+ render(btnEl.dataset.type)
4600
+ })
4601
+ })
4602
+
4603
+ chart.on('click', (p) => {
4604
+ try {
4605
+ if (!p || p.componentType !== 'series') return
4606
+ const label = p.axisValue || p.name
4607
+ const author = p.seriesName
4608
+ if (!label || !author) return
4609
+ const type =
4610
+ document.querySelector('#tabsTotalLunchTime button.active')?.dataset
4611
+ .type || 'daily'
4612
+
4613
+ const filteredCommits = commits.filter((c) => {
4614
+ const a = c.author || 'unknown'
4615
+ if (a !== author) return false
4616
+ const d = new Date(c.date)
4617
+ if (Number.isNaN(d.valueOf())) return false
4618
+ const h = d.getHours()
4619
+ const m = d.getMinutes()
4620
+ const hourDecimal = h + m / 60
4621
+ if (!(hourDecimal >= lunchStart && hourDecimal < lunchEnd)) return false
4622
+
4623
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
4624
+ if (type === 'weekly') {
4625
+ if (!label.includes('-W')) return false
4626
+ const [yy, ww] = label.split('-W')
4627
+ const range = getISOWeekRange(Number(yy), Number(ww))
4628
+ const day = d.toISOString().slice(0, 10)
4629
+ return day >= range.start && day <= range.end
4630
+ }
4631
+ if (type === 'monthly') {
4632
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
4633
+ return month === label
4634
+ }
4635
+ const year = String(d.getFullYear())
4636
+ return year === label
4637
+ })
4638
+
4639
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
4640
+
4641
+ if (type === 'weekly') {
4642
+ const weeklyItem = {
4643
+ outsideWorkCount: filteredCommits.length,
4644
+ outsideWorkRate: 0
4645
+ }
4646
+ showSideBarForWeek({
4647
+ period: label,
4648
+ weeklyItem,
4649
+ commits: filteredCommits,
4650
+ titleDrawer: `${author} 午休本周详情`
4651
+ })
4652
+ } else {
4653
+ showDayDetailSidebar({
4654
+ date: label,
4655
+ count: filteredCommits.length,
4656
+ commits: filteredCommits,
4657
+ titleDrawer: `${author} 午休 ${type} 详情`
4658
+ })
4659
+ }
4660
+ } catch (err) {
4661
+ console.warn('Total lunch chart click handler error', err)
4662
+ }
4663
+ })
4664
+
4665
+ return chart
4666
+ }
4667
+
4668
+ function renderLunchWeeklyRankSummary(
4669
+ commits,
4670
+ { lunchStart = 12, lunchEnd = 14 } = {}
4671
+ ) {
4672
+ const box = document.getElementById('lunchWeeklyRankSummary')
4673
+ if (!box) return
4674
+
4675
+ const now = new Date()
4676
+ const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
4677
+
4678
+ const weekDays = new Map() // week -> Map(author -> Set(dates))
4679
+ commits.forEach((c) => {
4680
+ const d = new Date(c.date)
4681
+ if (Number.isNaN(d.valueOf())) return
4682
+ const h = d.getHours()
4683
+ const m = d.getMinutes()
4684
+ if (!(h >= lunchStart && h < lunchEnd)) return
4685
+ const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
4686
+ if (wKey !== curKey) return
4687
+ const author = c.author || 'unknown'
4688
+ if (!weekDays.has(author)) weekDays.set(author, new Set())
4689
+ weekDays.get(author).add(d.toISOString().slice(0, 10))
4690
+ })
4691
+
4692
+ const weeklyRanks = []
4693
+ weekDays.forEach((set, author) => {
4694
+ weeklyRanks.push({ author, days: set.size })
4695
+ })
4696
+ weeklyRanks.sort(
4697
+ (a, b) =>
4698
+ b.days - a.days || String(a.author).localeCompare(String(b.author))
4699
+ )
4700
+
4701
+ const lines = []
4702
+ lines.push('【本周午休清醒者排行榜】')
4703
+ if (weeklyRanks.length === 0) {
4704
+ lines.push('本周无人午休提交,暂无清醒者排行榜。')
4705
+ } else {
4706
+ weeklyRanks.forEach((r, idx) => {
4707
+ const rank = idx + 1
4708
+ const medal =
4709
+ rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
4710
+ const title = rank === 1 ? '(状元・昼魔侠)' : ''
4711
+ lines.push(`${rank}. ${medal}${r.author} — ${r.days} 天${title}`)
4712
+ })
4713
+ }
4714
+
4715
+ box.innerHTML = `
4716
+ <div class="risk-summary">
4717
+ <div class="risk-title">【本周午休清醒者排行榜】</div>
4718
+ <ul>
4719
+ ${lines
4720
+ .slice(1)
4721
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
4722
+ .join('')}
4723
+ </ul>
4724
+ </div>
4725
+ `
4726
+ }
4727
+
4728
+ function renderLunchWeeklyRiskSummary(
4729
+ commits,
4730
+ { lunchStart = 12, lunchEnd = 14 } = {}
4731
+ ) {
4732
+ const box = document.getElementById('lunchWeeklyRiskSummary')
4733
+ if (!box) return
4734
+
4735
+ const now = new Date()
4736
+ const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
4737
+ const prev = new Date(now)
4738
+ prev.setDate(prev.getDate() - 7)
4739
+ const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
4740
+
4741
+ const weekMax = new Map() // week -> Map(author -> {val, date, time})
4742
+ commits.forEach((c) => {
4743
+ const d = new Date(c.date)
4744
+ if (Number.isNaN(d.valueOf())) return
4745
+ const h = d.getHours()
4746
+ const m = d.getMinutes()
4747
+ if (!(h >= lunchStart && h < lunchEnd)) return
4748
+ const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
4749
+ if (!wKey) return
4750
+
4751
+ if (!weekMax.has(wKey)) weekMax.set(wKey, new Map())
4752
+ const mMap = weekMax.get(wKey)
4753
+ const author = c.author || 'unknown'
4754
+ const val = h + m / 60
4755
+ const cur = mMap.get(author)
4756
+ if (!cur || val > cur.val)
4757
+ mMap.set(author, {
4758
+ val,
4759
+ date: d.toISOString().slice(0, 10),
4760
+ time: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
4761
+ })
4762
+ })
4763
+
4764
+ const curMap = weekMax.get(curKey) || new Map()
4765
+ const prevMap = weekMax.get(prevKey) || new Map()
4766
+
4767
+ let topAuthor = null
4768
+ let top = { val: -1, date: null, time: null }
4769
+ curMap.forEach((v, k) => {
4770
+ if (v.val > top.val) {
2318
4771
  top = v
2319
4772
  topAuthor = k
2320
4773
  }
@@ -2322,34 +4775,174 @@ function renderLatestMonthlyRiskSummary(
2322
4775
 
2323
4776
  let prevMax = -1
2324
4777
  prevMap.forEach((v) => {
2325
- if (v.max > prevMax) prevMax = v.max
4778
+ if (v.val > prevMax) prevMax = v.val
2326
4779
  })
2327
4780
 
2328
4781
  const lines = []
2329
- lines.push('【本月最晚加班风险】')
4782
+ lines.push('【本周午休最晚提交风险】')
2330
4783
 
2331
- if (top.max < 0) {
2332
- lines.push('本月尚无下班后/凌晨提交,未发现明显风险。')
4784
+ if (top.val < 0) {
4785
+ lines.push('本周午休期间暂无提交记录。')
4786
+ } else {
4787
+ let trend = '暂无上周对比'
4788
+ if (prevMax >= 0) {
4789
+ if (top.val > prevMax) trend = '较上周更晚'
4790
+ else if (top.val < prevMax) trend = '较上周提前'
4791
+ else trend = '与上周持平'
4792
+ }
4793
+ lines.push(
4794
+ `${topAuthor} 本周午休最晚提交:${top.time}(${top.date}),${trend}。)`
4795
+ )
4796
+ if (top.val >= lunchEnd - 0.5)
4797
+ lines.push('存在午间延迟提交风险,请关注短时间内频繁占用午休。')
4798
+ }
4799
+
4800
+ box.innerHTML = `
4801
+ <div class="risk-summary">
4802
+ <div class="risk-title">【本周午休最晚提交风险】</div>
4803
+ <ul>
4804
+ ${lines
4805
+ .slice(1)
4806
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
4807
+ .join('')}
4808
+ </ul>
4809
+ </div>
4810
+ `
4811
+ }
4812
+
4813
+ function renderLunchMonthlyRankSummary(
4814
+ commits,
4815
+ { lunchStart = 12, lunchEnd = 14 } = {}
4816
+ ) {
4817
+ const box = document.getElementById('lunchMonthlyRankSummary')
4818
+ if (!box) return
4819
+
4820
+ const now = new Date()
4821
+ const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
4822
+
4823
+ const monthDays = new Map() // author -> Set(dates)
4824
+ commits.forEach((c) => {
4825
+ const d = new Date(c.date)
4826
+ if (Number.isNaN(d.valueOf())) return
4827
+ const h = d.getHours()
4828
+ const m = d.getMinutes()
4829
+ if (!(h >= lunchStart && h < lunchEnd)) return
4830
+ const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
4831
+ if (mKey !== curKey) return
4832
+ const author = c.author || 'unknown'
4833
+ if (!monthDays.has(author)) monthDays.set(author, new Set())
4834
+ monthDays.get(author).add(d.toISOString().slice(0, 10))
4835
+ })
4836
+
4837
+ const monthlyRanks = []
4838
+ monthDays.forEach((set, author) => {
4839
+ monthlyRanks.push({ author, days: set.size })
4840
+ })
4841
+ monthlyRanks.sort(
4842
+ (a, b) =>
4843
+ b.days - a.days || String(a.author).localeCompare(String(b.author))
4844
+ )
4845
+
4846
+ const lines = []
4847
+ lines.push('【本月午休清醒者排行榜】')
4848
+ if (monthlyRanks.length === 0) {
4849
+ lines.push('本月无人午休提交,暂无清醒者排行榜。')
4850
+ } else {
4851
+ monthlyRanks.forEach((r, idx) => {
4852
+ const rank = idx + 1
4853
+ const medal =
4854
+ rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
4855
+ const title = rank === 1 ? '(状元・昼魔侠)' : ''
4856
+ lines.push(`${rank}. ${medal}${r.author} — ${r.days} 天${title}`)
4857
+ })
4858
+ }
4859
+
4860
+ box.innerHTML = `
4861
+ <div class="risk-summary">
4862
+ <div class="risk-title">【本月午休清醒者排行榜】</div>
4863
+ <ul>
4864
+ ${lines
4865
+ .slice(1)
4866
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
4867
+ .join('')}
4868
+ </ul>
4869
+ </div>
4870
+ `
4871
+ }
4872
+
4873
+ function renderLunchMonthlyRiskSummary(
4874
+ commits,
4875
+ { lunchStart = 12, lunchEnd = 14 } = {}
4876
+ ) {
4877
+ const box = document.getElementById('lunchMonthlyRiskSummary')
4878
+ if (!box) return
4879
+
4880
+ const now = new Date()
4881
+ const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
4882
+ const prev = new Date(now)
4883
+ prev.setMonth(prev.getMonth() - 1)
4884
+ const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
4885
+
4886
+ const monthMax = new Map()
4887
+ commits.forEach((c) => {
4888
+ const d = new Date(c.date)
4889
+ if (Number.isNaN(d.valueOf())) return
4890
+ const h = d.getHours()
4891
+ const m = d.getMinutes()
4892
+ if (!(h >= lunchStart && h < lunchEnd)) return
4893
+ const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
4894
+ if (!monthMax.has(mKey)) monthMax.set(mKey, new Map())
4895
+ const mm = monthMax.get(mKey)
4896
+ const author = c.author || 'unknown'
4897
+ const val = h + m / 60
4898
+ const cur = mm.get(author)
4899
+ if (!cur || val > cur.val)
4900
+ mm.set(author, {
4901
+ val,
4902
+ date: d.toISOString().slice(0, 10),
4903
+ time: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
4904
+ })
4905
+ })
4906
+
4907
+ const curMap = monthMax.get(curKey) || new Map()
4908
+ const prevMap = monthMax.get(prevKey) || new Map()
4909
+
4910
+ let topAuthor = null
4911
+ let top = { val: -1, date: null }
4912
+ curMap.forEach((v, k) => {
4913
+ if (v.val > top.val) {
4914
+ top = v
4915
+ topAuthor = k
4916
+ }
4917
+ })
4918
+
4919
+ let prevMax = -1
4920
+ prevMap.forEach((v) => {
4921
+ if (v.val > prevMax) prevMax = v.val
4922
+ })
4923
+
4924
+ const lines = []
4925
+ lines.push('【本月午休最晚提交风险】')
4926
+
4927
+ if (top.val < 0) {
4928
+ lines.push('本月午休期间暂无提交记录。')
2333
4929
  } else {
2334
4930
  let trend = '暂无上月对比'
2335
4931
  if (prevMax >= 0) {
2336
- if (top.max > prevMax) trend = '较上月更晚'
2337
- else if (top.max < prevMax) trend = '较上月提前'
4932
+ if (top.val > prevMax) trend = '较上月更晚'
4933
+ else if (top.val < prevMax) trend = '较上月提前'
2338
4934
  else trend = '与上月持平'
2339
4935
  }
2340
4936
  lines.push(
2341
- `${topAuthor} 本月最晚超出下班 ${top.max.toFixed(2)} 小时(${top.date}),${trend}。`
4937
+ `${topAuthor} 本月午休最晚提交:${top.time}(${top.date}),${trend}。)`
2342
4938
  )
2343
- if (top.max >= 2) {
2344
- lines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
2345
- } else if (top.max >= 1) {
2346
- lines.push('已超过 1 小时,注意控制夜间工作时长。')
2347
- }
4939
+ if (top.val >= lunchEnd - 0.5)
4940
+ lines.push('存在午间延迟提交风险,请关注短时间内频繁占用午休。')
2348
4941
  }
2349
4942
 
2350
4943
  box.innerHTML = `
2351
4944
  <div class="risk-summary">
2352
- <div class="risk-title">【本月最晚加班风险】</div>
4945
+ <div class="risk-title">【本月午休最晚提交风险】</div>
2353
4946
  <ul>
2354
4947
  ${lines
2355
4948
  .slice(1)
@@ -2360,24 +4953,380 @@ function renderLatestMonthlyRiskSummary(
2360
4953
  `
2361
4954
  }
2362
4955
 
4956
+ // ====== 前端计算 overtime 数据的函数 ======
4957
+
4958
+ /**
4959
+ * 根据 commits 和配置计算小时加班统计
4960
+ */
4961
+ function computeHourlyOvertime(commits, config) {
4962
+ const startHour = config.startHour ?? 9
4963
+ const endHour = config.endHour ?? 18
4964
+ const lunchStart = config.lunchStart ?? 12
4965
+ const lunchEnd = config.lunchEnd ?? 14
4966
+
4967
+ const hourlyCommits = Array(24).fill(0)
4968
+ const hourlyOvertimeCommits = Array(24).fill(0)
4969
+ const hourlyOvertimePercent = Array(24).fill(0)
4970
+
4971
+ let latestCommitHour = -1
4972
+ let latestCommit = null
4973
+ let total = 0
4974
+ let outsideWorkCount = 0
4975
+ let latestOutsideCommit = null
4976
+ let latestOutsideCommitHour = -1
4977
+
4978
+ commits.forEach((c) => {
4979
+ const d = new Date(c.date)
4980
+ if (isNaN(d.getTime())) return
4981
+
4982
+ const h = d.getHours()
4983
+ const m = d.getMinutes()
4984
+
4985
+ hourlyCommits[h]++
4986
+ total++
4987
+
4988
+ // 更新最后一条提交
4989
+ if (!latestCommit || new Date(c.date) > new Date(latestCommit.date)) {
4990
+ latestCommit = c
4991
+ latestCommitHour = h
4992
+ }
4993
+
4994
+ // 判断是否加班:与后端保持一致——非工作时间即为加班
4995
+ // 工作时间定义:startHour <= hour < endHour,且排除午休区间
4996
+ const inWorkHours =
4997
+ h >= startHour && h < endHour && !(h >= lunchStart && h < lunchEnd)
4998
+ const isOvertime = !inWorkHours
4999
+
5000
+ if (isOvertime) {
5001
+ hourlyOvertimeCommits[h]++
5002
+ outsideWorkCount++
5003
+ // 跟踪最晚的加班提交(按严重度:小时越大越晚)
5004
+ if (!latestOutsideCommit) {
5005
+ latestOutsideCommit = c
5006
+ latestOutsideCommitHour = h
5007
+ } else {
5008
+ const curSev = h >= endHour ? h - endHour : 24 - endHour + h
5009
+ const prevSev =
5010
+ latestOutsideCommitHour >= endHour
5011
+ ? latestOutsideCommitHour - endHour
5012
+ : 24 - endHour + latestOutsideCommitHour
5013
+ if (
5014
+ curSev > prevSev ||
5015
+ (curSev === prevSev &&
5016
+ new Date(c.date) > new Date(latestOutsideCommit.date))
5017
+ ) {
5018
+ latestOutsideCommit = c
5019
+ latestOutsideCommitHour = h
5020
+ }
5021
+ }
5022
+ }
5023
+ })
5024
+
5025
+ // 计算百分比
5026
+ for (let i = 0; i < 24; i++) {
5027
+ hourlyOvertimePercent[i] = total > 0 ? hourlyOvertimeCommits[i] / total : 0
5028
+ }
5029
+
5030
+ return {
5031
+ startHour,
5032
+ endHour,
5033
+ lunchStart,
5034
+ lunchEnd,
5035
+ hourlyOvertimeCommits,
5036
+ hourlyOvertimePercent,
5037
+ latestCommitHour,
5038
+ latestCommit,
5039
+ latestOutsideCommit,
5040
+ latestOutsideCommitHour,
5041
+ total,
5042
+ outsideWorkCount,
5043
+ outsideWorkRate: total > 0 ? outsideWorkCount / total : 0
5044
+ }
5045
+ }
5046
+
5047
+ /**
5048
+ * 根据 commits 计算每周加班统计
5049
+ */
5050
+ function computeWeeklyOvertime(
5051
+ commits,
5052
+ startHour,
5053
+ endHour,
5054
+ cutoff,
5055
+ lunchStart,
5056
+ lunchEnd
5057
+ ) {
5058
+ const weekMap = new Map()
5059
+
5060
+ // 第一步:按周分组统计加班提交
5061
+ commits.forEach((c) => {
5062
+ const d = new Date(c.date)
5063
+ const h = d.getHours()
5064
+
5065
+ // 判断是否在工作时间(与后端保持一致)
5066
+ // 工作时间是 startHour <= hour < endHour,但排除午休 lunchStart <= hour < lunchEnd
5067
+ const inWorkHours =
5068
+ h >= startHour && h < endHour && !(h >= lunchStart && h < lunchEnd)
5069
+ const isOvertime = !inWorkHours
5070
+ if (!isOvertime) return
5071
+
5072
+ const weekKey = getIsoWeekKey(d.toISOString().slice(0, 10))
5073
+ if (!weekKey) return
5074
+
5075
+ if (!weekMap.has(weekKey)) {
5076
+ weekMap.set(weekKey, {
5077
+ period: weekKey,
5078
+ outsideWorkCount: 0,
5079
+ outsideWorkRate: 0,
5080
+ range: { start: '', end: '' }
5081
+ })
5082
+ }
5083
+
5084
+ weekMap.get(weekKey).outsideWorkCount++
5085
+ })
5086
+
5087
+ // 第二步:计算每周的总 commits 数以便计算比例
5088
+ const totalByWeek = new Map()
5089
+ commits.forEach((c) => {
5090
+ const d = new Date(c.date)
5091
+ const weekKey = getIsoWeekKey(d.toISOString().slice(0, 10))
5092
+ if (weekKey) {
5093
+ totalByWeek.set(weekKey, (totalByWeek.get(weekKey) || 0) + 1)
5094
+ }
5095
+ })
5096
+
5097
+ // 第三步:计算比例并填充周范围
5098
+ const weekly = Array.from(weekMap.values())
5099
+ weekly.forEach((w) => {
5100
+ const total = totalByWeek.get(w.period) || 1
5101
+ w.outsideWorkRate = w.outsideWorkCount / total
5102
+
5103
+ // 填充周的日期范围
5104
+ const [yy, ww] = w.period.split('-W')
5105
+ w.range = getISOWeekRange(Number(yy), Number(ww))
5106
+ })
5107
+
5108
+ return weekly.sort((a, b) => a.period.localeCompare(b.period))
5109
+ }
5110
+
5111
+ /**
5112
+ * 根据 commits 计算每月加班统计
5113
+ */
5114
+ function computeMonthlyOvertime(
5115
+ commits,
5116
+ startHour,
5117
+ endHour,
5118
+ cutoff,
5119
+ lunchStart,
5120
+ lunchEnd
5121
+ ) {
5122
+ const monthMap = new Map()
5123
+
5124
+ commits.forEach((c) => {
5125
+ const d = new Date(c.date)
5126
+ const h = d.getHours()
5127
+
5128
+ // 判断是否在工作时间(与后端保持一致)
5129
+ const inWorkHours =
5130
+ h >= startHour && h < endHour && !(h >= lunchStart && h < lunchEnd)
5131
+ const isOvertime = !inWorkHours
5132
+ if (!isOvertime) return
5133
+
5134
+ const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
5135
+
5136
+ if (!monthMap.has(monthKey)) {
5137
+ monthMap.set(monthKey, {
5138
+ period: monthKey,
5139
+ outsideWorkCount: 0,
5140
+ outsideWorkRate: 0
5141
+ })
5142
+ }
5143
+
5144
+ monthMap.get(monthKey).outsideWorkCount++
5145
+ })
5146
+
5147
+ // 计算比例
5148
+ const totalByMonth = new Map()
5149
+ commits.forEach((c) => {
5150
+ const d = new Date(c.date)
5151
+ const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
5152
+ totalByMonth.set(monthKey, (totalByMonth.get(monthKey) || 0) + 1)
5153
+ })
5154
+
5155
+ const monthly = Array.from(monthMap.values())
5156
+ monthly.forEach((m) => {
5157
+ const total = totalByMonth.get(m.period) || 1
5158
+ m.outsideWorkRate = m.outsideWorkCount / total
5159
+ })
5160
+
5161
+ return monthly.sort((a, b) => a.period.localeCompare(b.period))
5162
+ }
5163
+
5164
+ /**
5165
+ * 根据 commits 计算每日最晚提交时间(所有工作时间外提交的最晚时刻)
5166
+ * 与后端逻辑保持一致:只看小时部分,忽略分钟
5167
+ */
5168
+ function computeLatestByDay(
5169
+ commits,
5170
+ startHour,
5171
+ endHour,
5172
+ cutoff,
5173
+ lunchStart,
5174
+ lunchEnd
5175
+ ) {
5176
+ const cutoffHour = cutoff || 6
5177
+
5178
+ // 第一步:按日期分组所有 commits(使用本地时间日期,避免时区偏移)
5179
+ const dayGroups = {}
5180
+ commits.forEach((c) => {
5181
+ const d = new Date(c.date)
5182
+ if (isNaN(d.getTime())) return
5183
+
5184
+ const dateStr = formatDateYMD(d) // YYYY-MM-DD 本地时间
5185
+ if (!dayGroups[dateStr]) {
5186
+ dayGroups[dateStr] = []
5187
+ }
5188
+ dayGroups[dateStr].push(c)
5189
+ })
5190
+
5191
+ const dayKeys = Object.keys(dayGroups).sort()
5192
+
5193
+ // 第二步:找出虚拟日期(次日凌晨有提交但前一日无记录)
5194
+ const virtualPrevDays = new Set()
5195
+ commits.forEach((c) => {
5196
+ const d = new Date(c.date)
5197
+ if (isNaN(d.getTime())) return
5198
+
5199
+ const h = d.getHours()
5200
+ // 只看凌晨 [0, cutoff) 且 < startHour 的提交
5201
+ if (h < 0 || h >= cutoffHour || h >= startHour) return
5202
+
5203
+ const curDay = formatDateYMD(d)
5204
+ // 计算前一天(本地日期)
5205
+ const prevDate = new Date(d)
5206
+ prevDate.setDate(prevDate.getDate() - 1)
5207
+ const prevDay = formatDateYMD(prevDate)
5208
+
5209
+ // 如果前一日没有任何提交记录,则添加虚拟日期
5210
+ if (!dayGroups[prevDay]) {
5211
+ virtualPrevDays.add(prevDay)
5212
+ }
5213
+ })
5214
+
5215
+ // 第三步:合并所有日期(实际 + 虚拟)
5216
+ const allDayKeys = Array.from(
5217
+ new Set([...dayKeys, ...virtualPrevDays])
5218
+ ).sort()
5219
+
5220
+ // 第四步:计算每一天的最晚提交时间
5221
+ const latestByDay = allDayKeys.map((k) => {
5222
+ const list = dayGroups[k] || []
5223
+
5224
+ // 1) 当天下班后的提交小时:>= endHour 且 < 24
5225
+ const sameDayHours = list
5226
+ .map((c) => new Date(c.date))
5227
+ .filter((d) => !isNaN(d.getTime()))
5228
+ .map((d) => d.getHours())
5229
+ .filter((h) => h >= endHour && h < 24)
5230
+
5231
+ // 2) 次日凌晨的提交小时:在 [0, cutoffHour) 且 < startHour
5232
+ // 构造次日的本地日期键(避免使用 UTC)
5233
+ const nextDate = new Date(`${k}T00:00:00`)
5234
+ nextDate.setDate(nextDate.getDate() + 1)
5235
+ const nextKey = formatDateYMD(nextDate)
5236
+ const early = dayGroups[nextKey] || []
5237
+ const earlyHours = early
5238
+ .map((c) => new Date(c.date))
5239
+ .filter((d) => !isNaN(d.getTime()))
5240
+ .map((d) => d.getHours())
5241
+ .filter((h) => h >= 0 && h < cutoffHour && h < startHour)
5242
+
5243
+ // 3) 合并时间值:当天用原始小时,次日凌晨用 24+小时
5244
+ const overtimeValues = [...sameDayHours, ...earlyHours.map((h) => 24 + h)]
5245
+
5246
+ // 如果没有任何下班后的提交,返回 null
5247
+ if (overtimeValues.length === 0) {
5248
+ return {
5249
+ date: k,
5250
+ latestHour: null,
5251
+ latestHourNormalized: null
5252
+ }
5253
+ }
5254
+
5255
+ const latestHourNormalized = Math.max(...overtimeValues)
5256
+ const sameDayMax =
5257
+ sameDayHours.length > 0 ? Math.max(...sameDayHours) : null
5258
+
5259
+ return {
5260
+ date: k,
5261
+ latestHour: sameDayMax,
5262
+ latestHourNormalized
5263
+ }
5264
+ })
5265
+
5266
+ return latestByDay
5267
+ }
5268
+
2363
5269
  async function main() {
2364
- const {
2365
- commits,
2366
- stats,
2367
- weekly,
2368
- monthly,
2369
- latestByDay,
2370
- config,
2371
- authorChanges
2372
- } = await loadData()
5270
+ const { commits, config, authorChanges, options } = await loadData()
2373
5271
  commitsAll = commits
2374
5272
  filtered = commitsAll.slice()
2375
- window.__overtimeEndHour =
2376
- stats && typeof stats.endHour === 'number'
2377
- ? stats.endHour
2378
- : (config.endHour ?? 18)
2379
- window.__overnightCutoff =
2380
- typeof config.overnightCutoff === 'number' ? config.overnightCutoff : 6
5273
+
5274
+ // 保存所有 commits 数据供小时分布图使用
5275
+ window.__allCommitsData = commits
5276
+
5277
+ // 保存采样起止(来自 /data/options.mjs 中的 period / serve 参数)供前端展示
5278
+ // 可能的形式:--since YYYY-MM-DD --until YYYY-MM-DD
5279
+ const period = options?.period || {}
5280
+ window.__samplingSince = period?.since || null
5281
+ window.__samplingUntil = period?.until || null
5282
+ // 格式化并保存采样的作者筛选信息(支持 string / { include:[], exclude:[] })
5283
+ window.__samplingAuthor = formatAuthorFilter(options?.author || null)
5284
+ window.__config = config
5285
+
5286
+ // 前端计算 overtime 数据
5287
+ const startHour = config.startHour ?? 9
5288
+ const endHour = config.endHour ?? 18
5289
+ const lunchStart = config.lunchStart ?? 12
5290
+ const lunchEnd = config.lunchEnd ?? 14
5291
+ const cutoff = config.overnightCutoff ?? 6
5292
+
5293
+ const stats = computeHourlyOvertime(commits, {
5294
+ startHour,
5295
+ endHour,
5296
+ lunchStart,
5297
+ lunchEnd
5298
+ })
5299
+
5300
+ const weekly = computeWeeklyOvertime(
5301
+ commits,
5302
+ startHour,
5303
+ endHour,
5304
+ cutoff,
5305
+ lunchStart,
5306
+ lunchEnd
5307
+ )
5308
+ const monthly = computeMonthlyOvertime(
5309
+ commits,
5310
+ startHour,
5311
+ endHour,
5312
+ cutoff,
5313
+ lunchStart,
5314
+ lunchEnd
5315
+ )
5316
+ const latestByDay = computeLatestByDay(
5317
+ commits,
5318
+ startHour,
5319
+ endHour,
5320
+ cutoff,
5321
+ lunchStart,
5322
+ lunchEnd
5323
+ )
5324
+
5325
+ window.__overtimeEndHour = endHour
5326
+ window.__overnightCutoff = cutoff
5327
+ window.__lunchStart = lunchStart
5328
+ window.__lunchEnd = lunchEnd
5329
+
2381
5330
  initTableControls()
2382
5331
  updatePager()
2383
5332
  renderCommitsTablePage()
@@ -2414,10 +5363,34 @@ async function main() {
2414
5363
  console.log('最累的一天:', daily.analysis.mostTiredDay)
2415
5364
 
2416
5365
  drawChangeTrends(authorChanges)
5366
+ // 开发者 累计提交次数(按日/周/月/年)
5367
+ drawAuthorTotalCommitsTrends(commits)
5368
+ // 开发者 累计提交Changed(按日/周/月/年)
5369
+ drawAuthorTotalCommitsChangedTrends(commits)
2417
5370
  drawAuthorOvertimeTrends(commits, stats)
2418
5371
  drawAuthorLatestOvertimeTrends(commits, stats)
5372
+ drawAuthorLunchTrends(commits, stats)
5373
+ // 新增:开发者累计午休时长(按日/周/月/年)
5374
+ drawAuthorTotalLunchTimeTrends(commits, stats)
5375
+ // 新增:开发者累计加班时长(按日/周/月/年)
5376
+ drawAuthorTotalOvertimeTrends(commits, stats)
2419
5377
  computeAndRenderLatestOvertime(latestByDay)
2420
5378
  renderKpi(stats)
5379
+
5380
+ const chartsContainer = document.getElementById('main')
5381
+ let ticking = false
5382
+ const resizeObserver = new ResizeObserver(() => {
5383
+ if (!ticking) {
5384
+ // 只要 #charts 容器大小变了(无论什么原因),都会执行
5385
+ window.requestAnimationFrame(() => {
5386
+ chartInstances.forEach((chart) => chart.resize())
5387
+ ticking = false
5388
+ })
5389
+ ticking = true
5390
+ }
5391
+ })
5392
+
5393
+ resizeObserver.observe(chartsContainer)
2421
5394
  }
2422
5395
 
2423
5396
  // 抽屉关闭交互(按钮 + 点击遮罩)