wukong-gitlog-cli 1.0.39 → 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 (125) hide show
  1. package/.eslintrc +1 -0
  2. package/.prettierrc +2 -1
  3. package/CHANGELOG.md +95 -0
  4. package/README.md +93 -173
  5. package/README.zh-CN.md +85 -137
  6. package/bin/wukong-gitlog-cli +0 -0
  7. package/doc//347/233/256/345/275/225/347/273/223/346/236/204.md +2871 -0
  8. package/package.json +32 -29
  9. package/rc/.wukonggitlogrc +53 -0
  10. package/scripts/compareHourlyCounts.mjs +42 -0
  11. package/scripts/compareLatest.mjs +106 -0
  12. package/src/app/analyzeAction.mjs +120 -0
  13. package/src/app/exportAction.mjs +215 -0
  14. package/src/app/exportActionProgress.mjs +37 -0
  15. package/src/app/helpers.mjs +292 -0
  16. package/src/app/initAction.mjs +110 -0
  17. package/src/app/initActionWithTemp.mjs +192 -0
  18. package/src/app/journalAction.mjs +117 -0
  19. package/src/app/overtimeAction.mjs +100 -0
  20. package/src/app/runProfileEnd.mjs +0 -0
  21. package/src/app/serveAction.mjs +73 -0
  22. package/src/app/versionAction.mjs +7 -0
  23. package/src/cli/defineOptions.mjs +209 -0
  24. package/src/cli/index.mjs +0 -0
  25. package/src/cli/parseOptions.mjs +126 -8
  26. package/src/constants/index.mjs +16 -2
  27. package/src/domain/author/analyze.mjs +6 -0
  28. package/src/domain/author/map.mjs +0 -0
  29. package/src/domain/export/exportAuthor.mjs +28 -0
  30. package/src/domain/export/exportAuthorChanges.mjs +27 -0
  31. package/src/domain/export/exportAuthorChangesJson.mjs +31 -0
  32. package/src/domain/export/exportByMonth.mjs +157 -0
  33. package/src/domain/export/exportByWeek.mjs +121 -0
  34. package/src/domain/export/exportCommits.mjs +26 -0
  35. package/src/domain/export/exportCommitsExcel.mjs +45 -0
  36. package/src/domain/export/exportCommitsJson.mjs +31 -0
  37. package/src/domain/export/index.mjs +91 -0
  38. package/src/domain/git/ensureGitAvailable.mjs +66 -0
  39. package/src/domain/git/ensureGitRepo.mjs +41 -0
  40. package/src/domain/git/getGitFeatures.mjs +59 -0
  41. package/src/domain/git/getGitLogs.mjs +326 -0
  42. package/src/domain/git/getGitUser.mjs +44 -0
  43. package/src/domain/git/getRepoRoot.mjs +32 -0
  44. package/src/domain/git/gitCapability.mjs +119 -0
  45. package/src/domain/git/index.mjs +96 -0
  46. package/src/domain/git/resolveGerrit.mjs +102 -0
  47. package/src/domain/overtime/analyze.mjs +48 -0
  48. package/src/domain/overtime/index.mjs +3 -0
  49. package/src/domain/overtime/perPeriod.mjs +15 -0
  50. package/src/domain/overtime/render.mjs +15 -0
  51. package/src/i18n/index.mjs +38 -0
  52. package/src/i18n/resources.mjs +252 -0
  53. package/src/index.mjs +132 -649
  54. package/src/infra/cache.mjs +0 -0
  55. package/src/infra/configStore.mjs +128 -0
  56. package/src/infra/fs.mjs +0 -0
  57. package/src/infra/path.mjs +0 -0
  58. package/src/output/csv/overtime.mjs +12 -0
  59. package/src/output/csv.mjs +0 -0
  60. package/src/output/data/readData.mjs +54 -0
  61. package/src/output/data/writeData.mjs +145 -0
  62. package/src/output/excel/commits.mjs +9 -0
  63. package/src/output/excel/outputExcelDayReport.mjs +92 -0
  64. package/src/output/excel/perPeriod.mjs +24 -0
  65. package/src/{excel.mjs → output/excel.mjs} +3 -2
  66. package/src/output/index.mjs +79 -0
  67. package/src/output/json/overtime.mjs +9 -0
  68. package/src/output/tab/overtime.mjs +12 -0
  69. package/src/output/tab.mjs +0 -0
  70. package/src/output/text/commits.mjs +9 -0
  71. package/src/output/text/index.mjs +3 -0
  72. package/src/output/text/outputTxtDayReport.mjs +74 -0
  73. package/src/output/text/overtime.mjs +18 -0
  74. package/src/output/utils/getEsmJs.mjs +10 -0
  75. package/src/output/utils/index.mjs +14 -0
  76. package/src/output/utils/outputPath.mjs +19 -0
  77. package/src/output/utils/writeFile.mjs +10 -0
  78. package/src/serve/index.mjs +0 -0
  79. package/src/{server.mjs → serve/startServer.mjs} +21 -3
  80. package/src/serve/writeData.mjs +0 -0
  81. package/src/utils/authorNormalizer.mjs +28 -2
  82. package/src/utils/buildAuthorChangeStats.mjs +44 -0
  83. package/src/utils/deepMerge.mjs +13 -0
  84. package/src/utils/getPackage.mjs +11 -0
  85. package/src/utils/getProfileDirFile.mjs +12 -0
  86. package/src/utils/{file.mjs → groupRecords.mjs} +8 -9
  87. package/src/utils/index.mjs +5 -2
  88. package/src/utils/logger.mjs +28 -17
  89. package/src/utils/profiler.mjs +0 -101
  90. package/src/utils/resolve.mjs +11 -0
  91. package/src/utils/showVersionInfo.mjs +6 -2
  92. package/src/utils/time.mjs +0 -0
  93. package/src/utils/wait.mjs +2 -0
  94. package/web/app.js +3197 -257
  95. package/web/index.html +171 -22
  96. package/web/revoke/alpha1/app.js +4324 -0
  97. package/web/revoke/alpha1/index.html +266 -0
  98. package/web/revoke/app.before.js +3139 -0
  99. package/web/revoke/index-before.html +181 -0
  100. package/web/static/style.css +116 -9
  101. package/src/git.mjs +0 -256
  102. package/src/handlers/handleServe.mjs +0 -203
  103. package/src/lib/configStore.mjs +0 -11
  104. package/src/lib/memoize.mjs +0 -14
  105. package/src/utils/analyzeOvertimeCached.mjs +0 -7
  106. package/src/utils/checkUpdate.mjs +0 -130
  107. package/src/utils/exitWithTime.mjs +0 -17
  108. package/src/utils/handleSuccess.mjs +0 -9
  109. package/src/utils/logDev.mjs +0 -19
  110. package/src/utils/output.mjs +0 -26
  111. package/src/utils/profiler/diff.mjs +0 -26
  112. package/src/utils/profiler/format.mjs +0 -11
  113. package/src/utils/profiler/index.mjs +0 -144
  114. package/src/utils/profiler/trace.mjs +0 -26
  115. package/src/utils/time/scopeTimer.mjs +0 -37
  116. package/src/utils/time/timer.mjs +0 -33
  117. package/src/utils/time/withTimer.mjs +0 -11
  118. package/src/utils/timer.mjs +0 -35
  119. /package/src/{overtime → domain/overtime}/createOvertimeStats.mjs +0 -0
  120. /package/src/{overtime → domain/overtime}/overtime.mjs +0 -0
  121. /package/src/{json.mjs → output/json.mjs} +0 -0
  122. /package/src/{renderAuthorMapText.mjs → output/renderAuthorMapText.mjs} +0 -0
  123. /package/src/{stats-text.mjs → output/stats-text.mjs} +0 -0
  124. /package/src/{stats.mjs → output/stats.mjs} +0 -0
  125. /package/src/{text.mjs → output/text.mjs} +0 -0
package/web/app.js CHANGED
@@ -1,8 +1,44 @@
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
+
6
42
  function filterByDate(commits) {
7
43
  const start = document.getElementById('startDate')?.value
8
44
  const end = document.getElementById('endDate')?.value
@@ -39,6 +75,38 @@ function formatDateYMD(d) {
39
75
  return `${yyyy}-${mm}-${dd}`
40
76
  }
41
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
+
42
110
  function getISOWeekRange(isoYear, isoWeek) {
43
111
  // 找到 ISO 年的第一个周一
44
112
  // ISO 年的第 1 周包含 1 月 4 日
@@ -61,44 +129,27 @@ function getISOWeekRange(isoYear, isoWeek) {
61
129
  }
62
130
 
63
131
  async function loadData() {
64
- try {
65
- const [
66
- commitsModule,
67
- statsModule,
68
- weeklyModule,
69
- monthlyModule,
70
- latestByDayModule,
71
- configModule,
72
- authorChangesModule
73
- ] = await Promise.all([
74
- import('/data/commits.mjs'),
75
- import('/data/overtime-stats.mjs'),
76
- import('/data/overtime-weekly.mjs'),
77
- import('/data/overtime-monthly.mjs').catch(() => ({ default: [] })),
78
- import('/data/overtime-latest-by-day.mjs').catch(() => ({ default: [] })),
79
- import('/data/config.mjs').catch(() => ({ default: {} })),
80
- import('/data/author-changes.mjs').catch(() => ({ default: {} }))
81
- ])
82
- const commits = commitsModule.default || []
83
- const stats = statsModule.default || {}
84
- const weekly = weeklyModule.default || []
85
- const monthly = monthlyModule.default || []
86
- const latestByDay = latestByDayModule.default || []
87
- const config = configModule.default || {}
88
- const authorChanges = authorChangesModule.default || {}
89
- return {
90
- commits,
91
- stats,
92
- weekly,
93
- monthly,
94
- latestByDay,
95
- config,
96
- 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
97
140
  }
98
- } catch (err) {
99
- console.error('Load data failed', err)
100
- return { commits: [], stats: {}, weekly: [], monthly: [], latestByDay: [] }
101
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 }
102
153
  }
103
154
 
104
155
  let commitsAll = []
@@ -111,9 +162,13 @@ function renderCommitsTablePage() {
111
162
  tbody.innerHTML = ''
112
163
  const start = (page - 1) * pageSize
113
164
  const end = start + pageSize
165
+ if(!window.__config.git.numstat) {
166
+ document.getElementById('thChanged').style.display = 'none'
167
+ }
114
168
  filtered.slice(start, end).forEach((c) => {
115
169
  const tr = document.createElement('tr')
116
- 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}`
117
172
  tbody.appendChild(tr)
118
173
  })
119
174
  document.getElementById('commitsTotal').textContent =
@@ -192,28 +247,61 @@ function initTableControls() {
192
247
 
193
248
  function drawHourlyOvertime(stats, onHourClick) {
194
249
  const el = document.getElementById('hourlyOvertimeChart')
250
+
251
+ const isEmpty = hideElementByObj({ el, objectName: stats })
252
+ if (isEmpty) {
253
+ return false
254
+ }
195
255
  const chart = echarts.init(el)
256
+ chartInstances.push(chart)
196
257
 
197
- const commits = stats.hourlyOvertimeCommits || []
198
- const percent = stats.hourlyOvertimePercent || []
258
+ // 显示所有提交数(不仅仅是加班)
259
+ const allCommits = Array(24).fill(0)
199
260
  const labels = Array.from({ length: 24 }, (_, i) =>
200
261
  String(i).padStart(2, '0')
201
262
  )
202
263
 
203
- // 颜色逻辑(与 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
+ // 颜色逻辑:根据时间段着色
204
277
  function getColor(h) {
205
- if (h >= 21) return '#d32f2f' // 深夜加班
206
- if (h >= 19) return '#fb8c00' // 夜间加班 橙
207
- if (h >= stats.lunchStart && h < stats.lunchEnd) return '#888888' // 午休灰
208
- if (h >= stats.startHour && h < stats.endHour) return '#1976d2' // 工作时段 蓝
209
- 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'
210
292
  }
211
293
 
212
- const data = commits.map((v, h) => ({
294
+ const data = allCommits.map((v, h) => ({
213
295
  value: v,
214
296
  itemStyle: { color: getColor(h) }
215
297
  }))
216
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
+
217
305
  chart.setOption({
218
306
  tooltip: {
219
307
  trigger: 'axis',
@@ -221,11 +309,31 @@ function drawHourlyOvertime(stats, onHourClick) {
221
309
  const p = params[0]
222
310
  const h = parseInt(p.axisValue, 10)
223
311
  const count = p.value
224
- 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
+
225
332
  return `
226
333
  🕒 <b>${h}:00</b><br/>
227
334
  提交次数:<b>${count}</b><br/>
228
- 占全天比例:<b>${rate}%</b>
335
+ 占全天比例:<b>${percent}%</b><br/>
336
+ 时段:${period}
229
337
  `
230
338
  }
231
339
  },
@@ -247,7 +355,7 @@ function drawHourlyOvertime(stats, onHourClick) {
247
355
  series: [
248
356
  {
249
357
  type: 'bar',
250
- name: 'Overtime commits',
358
+ name: '每小时提交',
251
359
  data,
252
360
  barWidth: 18,
253
361
 
@@ -260,7 +368,7 @@ function drawHourlyOvertime(stats, onHourClick) {
260
368
  name: '最晚提交',
261
369
  coord: [
262
370
  String(stats.latestCommitHour).padStart(2, '0'),
263
- commits[stats.latestCommitHour]
371
+ allCommits[stats.latestCommitHour] || 0
264
372
  ]
265
373
  }
266
374
  ]
@@ -307,14 +415,21 @@ function drawHourlyOvertime(stats, onHourClick) {
307
415
  }
308
416
  document.getElementById('dayDetailSidebar').classList.remove('show')
309
417
  if (Number.isNaN(hour)) return
310
- 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)
311
426
  })
312
427
  }
313
428
 
314
429
  return chart
315
430
  }
316
431
 
317
- // showSideBarForHour 实现
432
+ // 每小时加班分布
318
433
  function showSideBarForHour({ hour, commitsOrCount, titleDrawer }) {
319
434
  // 支持传入 number(仅次数)或 array(详细 commit 列表)
320
435
  // 统一复用通用详情侧栏 DOM
@@ -344,14 +459,14 @@ function showSideBarForHour({ hour, commitsOrCount, titleDrawer }) {
344
459
  // commits 列表:展示作者/时间/消息(最多前 50 条,避免性能问题)
345
460
  const commits = commitsOrCount.slice(0, 50)
346
461
  contentEl.innerHTML = `<div class="sidebar-list">${commits
347
- .map((c) => {
462
+ .map((c, index) => {
348
463
  const author = c.author ?? c.name ?? 'unknown'
349
464
  const time = c.date ?? c.time ?? ''
350
465
  const msg = (c.message ?? c.msg ?? c.body ?? '').replace(/\n/g, ' ')
351
466
  return `
352
467
  <div class="sidebar-item">
353
468
  <div class="sidebar-item-header">
354
- <span class="author">👤 ${escapeHtml(author)}</span>
469
+ <span class="author">${index + 1}. 👤 ${escapeHtml(author)}</span>
355
470
  <span class="time">🕒 ${escapeHtml(time)}</span>
356
471
  </div>
357
472
  <div class="sidebar-item-message">${escapeHtml(msg)}</div>
@@ -383,30 +498,121 @@ function escapeHtml(str = '') {
383
498
  .replaceAll("'", '&#39;')
384
499
  }
385
500
 
386
- function drawOutsideVsInside(stats) {
387
- const el = document.getElementById('outsideVsInsideChart')
388
- // eslint-disable-next-line no-undef
389
- const chart = echarts.init(el)
390
- const outside = stats.outsideWorkCount || 0
391
- const total = stats.total || 0
392
- 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
+
393
530
  chart.setOption({
394
- 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
+
395
574
  series: [
396
575
  {
397
576
  type: 'pie',
398
- radius: '55%',
399
- data: [
400
- { value: inside, name: '工作时间内' },
401
- { value: outside, name: '下班时间' }
402
- ]
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
403
588
  }
404
589
  ]
405
590
  })
591
+
406
592
  return chart
407
593
  }
408
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
+
409
613
  function drawDailyTrend(commits, onDayClick) {
614
+ const el = document.getElementById('dailyTrendChart')
615
+
410
616
  if (!Array.isArray(commits) || commits.length === 0) return null
411
617
 
412
618
  // 聚合每日提交数量
@@ -419,11 +625,11 @@ function drawDailyTrend(commits, onDayClick) {
419
625
  const labels = Array.from(map.keys()).sort()
420
626
  const data = labels.map((l) => map.get(l))
421
627
 
422
- const el = document.getElementById('dailyTrendChart')
423
628
  const titleDrawer = el.getAttribute('data-title') || ''
424
629
 
425
630
  // eslint-disable-next-line no-undef
426
631
  const chart = echarts.init(el)
632
+ chartInstances.push(chart)
427
633
 
428
634
  chart.setOption({
429
635
  tooltip: {
@@ -550,14 +756,14 @@ function showSideBarForWeek({ period, weeklyItem, commits = [], titleDrawer }) {
550
756
  html += `<div style="padding:10px;color:#777;">该周无提交记录</div>`
551
757
  } else {
552
758
  html += `<div class="sidebar-list">${commits
553
- .map((c) => {
759
+ .map((c, index) => {
554
760
  const author = escapeHtml(c.author || 'unknown')
555
761
  const time = escapeHtml(c.date || '')
556
762
  const msg = escapeHtml((c.message || '').replace(/\n/g, ' '))
557
763
  return `
558
764
  <div class="sidebar-item">
559
765
  <div class="sidebar-item-header">
560
- <span class="author">👤 ${author}</span>
766
+ <span class="author">${index + 1}👤 ${author}</span>
561
767
  <span class="time">🕒 ${time}</span>
562
768
  </div>
563
769
  <div class="sidebar-item-message">${msg}</div>
@@ -573,16 +779,23 @@ function showSideBarForWeek({ period, weeklyItem, commits = [], titleDrawer }) {
573
779
  }
574
780
 
575
781
  function drawWeeklyTrend(weekly, commits, onWeekClick) {
576
- 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
+ }
577
790
 
578
791
  const labels = weekly.map((w) => w.period)
579
792
  const dataRate = weekly.map((w) => +(w.outsideWorkRate * 100).toFixed(1)) // %
580
793
  const dataCount = weekly.map((w) => w.outsideWorkCount)
581
794
 
582
- const el = document.getElementById('weeklyTrendChart')
583
795
  const titleDrawer = el.getAttribute('data-title') || ''
584
796
 
585
797
  const chart = echarts.init(el)
798
+ chartInstances.push(chart)
586
799
 
587
800
  chart.setOption({
588
801
  tooltip: {
@@ -698,15 +911,20 @@ function drawWeeklyTrend(weekly, commits, onWeekClick) {
698
911
  }
699
912
 
700
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
+ }
701
919
  if (!Array.isArray(monthly) || monthly.length === 0) return null
702
920
 
703
921
  const labels = monthly.map((m) => m.period)
704
922
  const dataRate = monthly.map((m) => +(m.outsideWorkRate * 100).toFixed(1)) // 0–100%
705
923
 
706
- const el = document.getElementById('monthlyTrendChart')
707
924
  const titleDrawer = el.getAttribute('data-title') || ''
708
925
  // eslint-disable-next-line no-undef
709
926
  const chart = echarts.init(el)
927
+ chartInstances.push(chart)
710
928
 
711
929
  chart.setOption({
712
930
  tooltip: {
@@ -821,6 +1039,11 @@ function drawMonthlyTrend(monthly, commits, onMonthClick) {
821
1039
  }
822
1040
 
823
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
+ }
824
1047
  if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
825
1048
 
826
1049
  const labels = latestByDay.map((d) => d.date)
@@ -849,11 +1072,11 @@ function drawLatestHourDaily(latestByDay, commits, onDayClick) {
849
1072
  const numericValues = raw.filter((v) => typeof v === 'number')
850
1073
  const maxV = numericValues.length > 0 ? Math.max(...numericValues) : 0
851
1074
 
852
- const el = document.getElementById('latestHourDailyChart')
853
1075
  const titleDrawer = el.getAttribute('data-title') || ''
854
1076
 
855
1077
  // eslint-disable-next-line no-undef
856
1078
  const chart = echarts.init(el)
1079
+ chartInstances.push(chart)
857
1080
 
858
1081
  chart.setOption({
859
1082
  tooltip: {
@@ -965,6 +1188,11 @@ function drawLatestHourDaily(latestByDay, commits, onDayClick) {
965
1188
  }
966
1189
 
967
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
+ }
968
1196
  if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
969
1197
 
970
1198
  const labels = latestByDay.map((d) => d.date)
@@ -980,11 +1208,11 @@ function drawDailySeverity(latestByDay, commits, onDayClick) {
980
1208
  // 这里按 0 小时加班处理,保证折线连续。
981
1209
  const sev = raw.map((v) => (v == null ? 0 : Math.max(0, Number(v) - endH)))
982
1210
 
983
- const el = document.getElementById('dailySeverityChart')
984
1211
  const titleDrawer = el.getAttribute('data-title') || ''
985
1212
 
986
1213
  // eslint-disable-next-line no-undef
987
1214
  const chart = echarts.init(el)
1215
+ chartInstances.push(chart)
988
1216
 
989
1217
  chart.setOption({
990
1218
  tooltip: {
@@ -1138,7 +1366,7 @@ function drawDailyTrendSeverity(commits, weekly, onDayClick) {
1138
1366
 
1139
1367
  // ---------- 3. 自动分析「最累的一周」 ----------
1140
1368
  let maxWeek = null
1141
- if (Array.isArray(weekly)) {
1369
+ if (Array.isArray(weekly) && weekly.length > 0) {
1142
1370
  maxWeek = weekly.reduce((a, b) =>
1143
1371
  a.outsideWorkCount > b.outsideWorkCount ? a : b
1144
1372
  )
@@ -1186,24 +1414,26 @@ function drawDailyTrendSeverity(commits, weekly, onDayClick) {
1186
1414
  const count = params?.[0].value
1187
1415
  const details = dayCommitsDetail[date] || []
1188
1416
 
1189
- let html = `📅 <b>${date}</b><br/>提交次数:${count}<br/><br/>`
1417
+ let html = `📅 <b>${date}</b><br/>提交次数:${count}<br/>`
1190
1418
 
1191
1419
  details.slice(0, 5).forEach((d) => {
1192
- 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>`
1193
1421
  })
1194
1422
 
1195
1423
  if (details.length > 5) {
1196
1424
  html += `(其余 ${details.length - 5} 条已省略)`
1197
1425
  }
1198
-
1426
+ html = `<div class="tooltip-box">${html}</div>`
1199
1427
  return html
1200
1428
  }
1201
1429
 
1202
1430
  // ---------- 7. 绘图 ----------
1203
1431
  const el = document.getElementById('dailyTrendChartDog')
1432
+
1204
1433
  const titleDrawer = el.getAttribute('data-title') || ''
1205
1434
 
1206
1435
  const chart = echarts.init(el)
1436
+ chartInstances.push(chart)
1207
1437
 
1208
1438
  chart.setOption({
1209
1439
  tooltip: {
@@ -1276,10 +1506,10 @@ function showDayDetailSidebar({ date, count, commits, titleDrawer }) {
1276
1506
  // 渲染详情
1277
1507
  content.innerHTML = commits
1278
1508
  .map(
1279
- (c) => `
1509
+ (c, index) => `
1280
1510
  <div class="sidebar-item">
1281
1511
  <div class="sidebar-item-header">
1282
- <span class="author">👤 ${escapeHtml(c.author || 'unknown')}</span>
1512
+ <span class="author">${index + 1}👤 ${escapeHtml(c.author || 'unknown')}</span>
1283
1513
  <span class="time">🕒 ${escapeHtml(c.time || c.date || '')}</span>
1284
1514
  </div>
1285
1515
  <div class="sidebar-item-message">${escapeHtml(c.msg || c.message || '')}</div>
@@ -1345,14 +1575,59 @@ function renderKpi(stats) {
1345
1575
  (latestOut ? new Date(latestOut.date).getHours() : null)
1346
1576
  }
1347
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
+
1348
1603
  const html = [
1349
- `<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,
1350
1605
  `<div class="hr"></div>`,
1351
- `<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>`,
1352
1607
  `<div class="hr"></div>`,
1353
- `<div>次日归并窗口:凌晨 <b>${cutoff}</b> 点内归前一日</div>`
1608
+ `<div>次日归并窗口:凌晨 <b>${cutoff}</b> 点内归前一日</div>`,
1609
+ samplingHtml
1354
1610
  ].join('')
1355
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
+ }
1356
1631
  }
1357
1632
 
1358
1633
  // 1) 按小时分组(例:commits 为原始提交数组)
@@ -1478,8 +1753,15 @@ function buildDataset(stats, type) {
1478
1753
 
1479
1754
  const drawChangeTrends = (stats) => {
1480
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
+ }
1481
1762
  if (!el) return null
1482
1763
  const chart = echarts.init(el)
1764
+ chartInstances.push(chart)
1483
1765
 
1484
1766
  function render(type) {
1485
1767
  const { authors, allPeriods, series } = buildDataset(stats, type)
@@ -1509,12 +1791,16 @@ const drawChangeTrends = (stats) => {
1509
1791
  // extra = `<div style="margin-top:4px;color:#999;font-size:12px">
1510
1792
  // 周区间:${start} ~ ${end}
1511
1793
  // </div>`
1512
- // TODO: remove debug log before production
1513
1794
  extra = ''
1514
1795
  }
1515
1796
 
1516
1797
  const lines = params
1517
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
+ )
1518
1804
  .map(
1519
1805
  (item) => `${item.marker}${item.seriesName}: ${item.data} 行变更`
1520
1806
  )
@@ -1547,6 +1833,61 @@ const drawChangeTrends = (stats) => {
1547
1833
  })
1548
1834
  })
1549
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
+
1550
1891
  return chart
1551
1892
  }
1552
1893
 
@@ -1595,6 +1936,7 @@ function drawAuthorOvertimeTrends(commits, stats) {
1595
1936
  const el = document.getElementById('chartAuthorOvertime')
1596
1937
  if (!el) return null
1597
1938
  const chart = echarts.init(el)
1939
+ chartInstances.push(chart)
1598
1940
 
1599
1941
  const startHour =
1600
1942
  typeof stats.startHour === 'number' && stats.startHour >= 0
@@ -1642,6 +1984,11 @@ function drawAuthorOvertimeTrends(commits, stats) {
1642
1984
 
1643
1985
  const lines = params
1644
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
+ )
1645
1992
  .map(
1646
1993
  (item) => `${item.marker}${item.seriesName}: ${item.data} 次提交`
1647
1994
  )
@@ -1676,13 +2023,75 @@ function drawAuthorOvertimeTrends(commits, stats) {
1676
2023
  })
1677
2024
  })
1678
2025
 
1679
- // 输出本周风险总结
2026
+ // 输出本周风险与加班时长排行
1680
2027
  renderWeeklyRiskSummary(commits, { startHour, endHour, cutoff })
1681
2028
  renderMonthlyRiskSummary(commits, { startHour, endHour, cutoff })
2029
+ // 新增:本周/本月加班时长排名(显示所有作者总时长,前三名带图标,状元标注“夜魔侠”)
2030
+ renderWeeklyDurationRankSummary(commits, { startHour, endHour, cutoff })
1682
2031
  renderWeeklyDurationRiskSummary(commits, { startHour, endHour, cutoff })
2032
+ renderMonthlyDurationRankSummary(commits, { startHour, endHour, cutoff })
1683
2033
  renderMonthlyDurationRiskSummary(commits, { startHour, endHour, cutoff })
1684
2034
  renderRolling30DurationRiskSummary(commits, { startHour, endHour, cutoff })
1685
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
+
1686
2095
  return chart
1687
2096
  }
1688
2097
 
@@ -1770,6 +2179,65 @@ function renderWeeklyRiskSummary(
1770
2179
  )
1771
2180
  }
1772
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
+
1773
2241
  box.innerHTML = `
1774
2242
  <div class="risk-summary">
1775
2243
  <div class="risk-title">【本周风险总结】</div>
@@ -1780,6 +2248,16 @@ function renderWeeklyRiskSummary(
1780
2248
  .join('')}
1781
2249
  </ul>
1782
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>
1783
2261
  `
1784
2262
  }
1785
2263
 
@@ -1812,6 +2290,62 @@ function computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff) {
1812
2290
  return byAuthorDay
1813
2291
  }
1814
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
+
1815
2349
  function renderWeeklyDurationRiskSummary(
1816
2350
  commits,
1817
2351
  { startHour = 9, endHour = 18, cutoff = 6 } = {}
@@ -1864,6 +2398,62 @@ function renderWeeklyDurationRiskSummary(
1864
2398
  `
1865
2399
  }
1866
2400
 
2401
+ function renderMonthlyDurationRankSummary(
2402
+ commits,
2403
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2404
+ ) {
2405
+ const box = document.getElementById('monthlyDurationRankSummary')
2406
+ if (!box) return
2407
+ const now = new Date()
2408
+ const curMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
2409
+ const byAuthorDay = computeAuthorDailyMaxOvertime(
2410
+ commits,
2411
+ startHour,
2412
+ endHour,
2413
+ cutoff
2414
+ )
2415
+ const ranks = []
2416
+ byAuthorDay.forEach((dayMap, author) => {
2417
+ let total = 0
2418
+ dayMap.forEach((v, dayKey) => {
2419
+ const m = dayKey.slice(0, 7)
2420
+ if (m === curMonth) total += v
2421
+ })
2422
+ if (total > 0) ranks.push({ author, total })
2423
+ })
2424
+ ranks.sort(
2425
+ (a, b) =>
2426
+ b.total - a.total || String(a.author).localeCompare(String(b.author))
2427
+ )
2428
+
2429
+ const lines = []
2430
+ lines.push('【本月加班时长排名】')
2431
+ if (ranks.length === 0) {
2432
+ lines.push('本月暂无加班时长。')
2433
+ } else {
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 ? '(状元・夜魔侠)' : ''
2439
+ lines.push(
2440
+ `${rank}. ${medal}${r.author} — ${r.total.toFixed(2)} 小时${title}`
2441
+ )
2442
+ })
2443
+ }
2444
+ box.innerHTML = `
2445
+ <div class="risk-summary">
2446
+ <div class="risk-title">【本月加班时长排名】</div>
2447
+ <ul>
2448
+ ${lines
2449
+ .slice(1)
2450
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2451
+ .join('')}
2452
+ </ul>
2453
+ </div>
2454
+ `
2455
+ }
2456
+
1867
2457
  function renderMonthlyDurationRiskSummary(
1868
2458
  commits,
1869
2459
  { startHour = 9, endHour = 18, cutoff = 6 } = {}
@@ -2068,13 +2658,45 @@ function renderMonthlyRiskSummary(
2068
2658
  }
2069
2659
  }
2070
2660
 
2071
- box.innerHTML = `
2072
- <div class="risk-summary">
2073
- <div class="risk-title">【本月加班风险】</div>
2074
- <ul>
2075
- ${lines
2076
- .slice(1)
2077
- .map((l) => `<li>${escapeHtml(l)}</li>`)
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
+
2683
+ box.innerHTML = `
2684
+ <div class="risk-summary">
2685
+ <div class="risk-title">【本月加班风险】</div>
2686
+ <ul>
2687
+ ${lines
2688
+ .slice(1)
2689
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2690
+ .join('')}
2691
+ </ul>
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>`)
2078
2700
  .join('')}
2079
2701
  </ul>
2080
2702
  </div>
@@ -2129,6 +2751,7 @@ function drawAuthorLatestOvertimeTrends(commits, stats) {
2129
2751
  const el = document.getElementById('chartAuthorLatestOvertime')
2130
2752
  if (!el) return null
2131
2753
  const chart = echarts.init(el)
2754
+ chartInstances.push(chart)
2132
2755
 
2133
2756
  const startHour =
2134
2757
  typeof stats.startHour === 'number' && stats.startHour >= 0
@@ -2176,6 +2799,11 @@ function drawAuthorLatestOvertimeTrends(commits, stats) {
2176
2799
 
2177
2800
  const lines = params
2178
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
+ )
2179
2807
  .map(
2180
2808
  (item) => `${item.marker}${item.seriesName}: ${item.data} 小时`
2181
2809
  )
@@ -2210,207 +2838,2495 @@ function drawAuthorLatestOvertimeTrends(commits, stats) {
2210
2838
  })
2211
2839
  })
2212
2840
 
2213
- renderLatestRiskSummary(commits, { startHour, endHour, cutoff })
2214
- 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
+ })
2215
2901
 
2216
2902
  return chart
2217
2903
  }
2218
2904
 
2219
- // 本周“最晚加班”风险提示
2220
- function renderLatestRiskSummary(
2905
+ // ====== 开发者 累计加班时长(按日/周/月/年累计日峰值加班时长求和) ======
2906
+ function buildAuthorTotalOvertimeDataset(
2221
2907
  commits,
2222
- { startHour = 9, endHour = 18, cutoff = 6 } = {}
2908
+ type,
2909
+ startHour,
2910
+ endHour,
2911
+ cutoff
2223
2912
  ) {
2224
- const box = document.getElementById('latestRiskSummary')
2225
- if (!box) return
2226
-
2227
- const now = new Date()
2228
- const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
2229
- const prev = new Date(now)
2230
- prev.setDate(prev.getDate() - 7)
2231
- const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
2232
-
2233
- // 统计每周每人最大超时
2234
- const weekMax = new Map() // week -> Map(author -> {max, date})
2235
- commits.forEach((c) => {
2236
- const d = new Date(c.date)
2237
- if (Number.isNaN(d.valueOf())) return
2238
- const h = d.getHours()
2239
- let overtime = null
2240
- if (h >= endHour && h < 24) overtime = h - endHour
2241
- else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
2242
- 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()
2243
2922
 
2244
- const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
2245
- if (!wKey) return
2246
- if (!weekMax.has(wKey)) weekMax.set(wKey, new Map())
2247
- const m = weekMax.get(wKey)
2248
- const author = c.author || 'unknown'
2249
- const cur = m.get(author)
2250
- if (!cur || overtime > cur.max) {
2251
- 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)
2252
2938
  }
2253
2939
  })
2254
2940
 
2255
- const curMap = weekMax.get(curKey) || new Map()
2256
- 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
+ }))
2257
2951
 
2258
- // 当前周的全局最晚
2259
- let topAuthor = null
2260
- let top = { max: -1, date: null }
2261
- curMap.forEach((v, k) => {
2262
- if (v.max > top.max) {
2263
- top = v
2264
- topAuthor = k
2265
- }
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 }
2266
2963
  })
2267
2964
 
2268
- // 上周全局最晚,用于趋势判断
2269
- let prevMax = -1
2270
- prevMap.forEach((v) => {
2271
- if (v.max > prevMax) prevMax = v.max
2272
- })
2965
+ return { authors, allPeriods, series, totals }
2966
+ }
2273
2967
 
2274
- const lines = []
2275
- 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)
2276
2973
 
2277
- if (top.max < 0) {
2278
- lines.push('本周尚无下班后/凌晨提交,未发现明显风险。')
2279
- } else {
2280
- let trend = '暂无上周对比'
2281
- if (prevMax >= 0) {
2282
- if (top.max > prevMax) trend = '较上周更晚'
2283
- else if (top.max < prevMax) trend = '较上周提前'
2284
- else trend = '与上周持平'
2285
- }
2286
- lines.push(
2287
- `${topAuthor} 本周最晚超出下班 ${top.max.toFixed(
2288
- 2
2289
- )} 小时(${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
2290
2991
  )
2291
- if (top.max >= 2) {
2292
- lines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
2293
- } else if (top.max >= 1) {
2294
- 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
+ }
2998
+ }
2999
+
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
+ })
3031
+
3032
+ // 同步更新下面的排名列表
3033
+ try {
3034
+ renderAuthorTotalOvertimeRankFromDs(ds, 0)
3035
+ renderAuthorTotalOvertimeRank(ds, 0)
3036
+ } catch (e) {
3037
+ console.warn('更新累计加班排名失败', e)
2295
3038
  }
2296
3039
  }
2297
3040
 
2298
- box.innerHTML = `
2299
- <div class="risk-summary">
2300
- <div class="risk-title">【本周最晚加班风险】</div>
2301
- <ul>
2302
- ${lines
2303
- .slice(1)
2304
- .map((l) => `<li>${escapeHtml(l)}</li>`)
2305
- .join('')}
2306
- </ul>
2307
- </div>
2308
- `
2309
- }
3041
+ render('daily')
2310
3042
 
2311
- function renderLatestMonthlyRiskSummary(
2312
- commits,
2313
- { startHour = 9, endHour = 18, cutoff = 6 } = {}
2314
- ) {
2315
- const box = document.getElementById('latestMonthlyRiskSummary')
2316
- if (!box) return
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
+ })
3050
+ })
2317
3051
 
2318
- const now = new Date()
2319
- const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
2320
- const prev = new Date(now)
2321
- prev.setMonth(prev.getMonth() - 1)
2322
- const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
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
+ })
2323
3089
 
2324
- const monthMax = new Map()
2325
- commits.forEach((c) => {
2326
- const d = new Date(c.date)
2327
- if (Number.isNaN(d.valueOf())) return
2328
- const h = d.getHours()
2329
- let overtime = null
2330
- if (h >= endHour && h < 24) overtime = h - endHour
2331
- else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
2332
- if (overtime == null) return
3090
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
2333
3091
 
2334
- const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2335
- if (!monthMax.has(mKey)) monthMax.set(mKey, new Map())
2336
- const m = monthMax.get(mKey)
2337
- const author = c.author || 'unknown'
2338
- const cur = m.get(author)
2339
- if (!cur || overtime > cur.max) {
2340
- m.set(author, { max: overtime, date: d.toISOString().slice(0, 10) })
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)
2341
3113
  }
2342
3114
  })
2343
3115
 
2344
- const curMap = monthMax.get(curKey) || new Map()
2345
- const prevMap = monthMax.get(prevKey) || new Map()
3116
+ return chart
3117
+ }
2346
3118
 
2347
- let topAuthor = null
2348
- let top = { max: -1, date: null }
2349
- curMap.forEach((v, k) => {
2350
- if (v.max > top.max) {
2351
- top = v
2352
- topAuthor = k
2353
- }
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)) }
2354
3138
  })
2355
3139
 
2356
- let prevMax = -1
2357
- prevMap.forEach((v) => {
2358
- if (v.max > prevMax) prevMax = v.max
2359
- })
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
+ }
2360
3160
 
2361
- const lines = []
2362
- lines.push('【本月最晚加班风险】')
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)
2363
3175
 
2364
- if (top.max < 0) {
2365
- lines.push('本月尚无下班后/凌晨提交,未发现明显风险。')
2366
- } else {
2367
- let trend = '暂无上月对比'
2368
- if (prevMax >= 0) {
2369
- if (top.max > prevMax) trend = '较上月更晚'
2370
- else if (top.max < prevMax) trend = '较上月提前'
2371
- else trend = '与上月持平'
2372
- }
2373
- lines.push(
2374
- `${topAuthor} 本月最晚超出下班 ${top.max.toFixed(2)} 小时(${top.date}),${trend}。`
2375
- )
2376
- if (top.max >= 2) {
2377
- lines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
2378
- } else if (top.max >= 1) {
2379
- lines.push('已超过 1 小时,注意控制夜间工作时长。')
2380
- }
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
+ })
2381
3181
  }
2382
3182
 
2383
- box.innerHTML = `
2384
- <div class="risk-summary">
2385
- <div class="risk-title">【本月最晚加班风险】</div>
2386
- <ul>
2387
- ${lines
2388
- .slice(1)
2389
- .map((l) => `<li>${escapeHtml(l)}</li>`)
2390
- .join('')}
2391
- </ul>
2392
- </div>
2393
- `
3183
+ // 5. 调用现有绘图方法
3184
+ return drawPieWithTotal({
3185
+ el: 'authorTotalOvertimeRankSummary',
3186
+ title: '加班总时长排名分布',
3187
+ unit: '小时',
3188
+ totalLabel: '总时长',
3189
+ data: chartData,
3190
+ colors: generateColors(chartData.length)
3191
+ })
2394
3192
  }
2395
3193
 
2396
- async function main() {
2397
- const {
2398
- commits,
2399
- stats,
2400
- weekly,
2401
- monthly,
2402
- latestByDay,
2403
- config,
2404
- authorChanges
2405
- } = await loadData()
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) {
4771
+ top = v
4772
+ topAuthor = k
4773
+ }
4774
+ })
4775
+
4776
+ let prevMax = -1
4777
+ prevMap.forEach((v) => {
4778
+ if (v.val > prevMax) prevMax = v.val
4779
+ })
4780
+
4781
+ const lines = []
4782
+ lines.push('【本周午休最晚提交风险】')
4783
+
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('本月午休期间暂无提交记录。')
4929
+ } else {
4930
+ let trend = '暂无上月对比'
4931
+ if (prevMax >= 0) {
4932
+ if (top.val > prevMax) trend = '较上月更晚'
4933
+ else if (top.val < prevMax) trend = '较上月提前'
4934
+ else trend = '与上月持平'
4935
+ }
4936
+ lines.push(
4937
+ `${topAuthor} 本月午休最晚提交:${top.time}(${top.date}),${trend}。)`
4938
+ )
4939
+ if (top.val >= lunchEnd - 0.5)
4940
+ lines.push('存在午间延迟提交风险,请关注短时间内频繁占用午休。')
4941
+ }
4942
+
4943
+ box.innerHTML = `
4944
+ <div class="risk-summary">
4945
+ <div class="risk-title">【本月午休最晚提交风险】</div>
4946
+ <ul>
4947
+ ${lines
4948
+ .slice(1)
4949
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
4950
+ .join('')}
4951
+ </ul>
4952
+ </div>
4953
+ `
4954
+ }
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
+
5269
+ async function main() {
5270
+ const { commits, config, authorChanges, options } = await loadData()
2406
5271
  commitsAll = commits
2407
5272
  filtered = commitsAll.slice()
2408
- window.__overtimeEndHour =
2409
- stats && typeof stats.endHour === 'number'
2410
- ? stats.endHour
2411
- : (config.endHour ?? 18)
2412
- window.__overnightCutoff =
2413
- 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
+
2414
5330
  initTableControls()
2415
5331
  updatePager()
2416
5332
  renderCommitsTablePage()
@@ -2447,10 +5363,34 @@ async function main() {
2447
5363
  console.log('最累的一天:', daily.analysis.mostTiredDay)
2448
5364
 
2449
5365
  drawChangeTrends(authorChanges)
5366
+ // 开发者 累计提交次数(按日/周/月/年)
5367
+ drawAuthorTotalCommitsTrends(commits)
5368
+ // 开发者 累计提交Changed(按日/周/月/年)
5369
+ drawAuthorTotalCommitsChangedTrends(commits)
2450
5370
  drawAuthorOvertimeTrends(commits, stats)
2451
5371
  drawAuthorLatestOvertimeTrends(commits, stats)
5372
+ drawAuthorLunchTrends(commits, stats)
5373
+ // 新增:开发者累计午休时长(按日/周/月/年)
5374
+ drawAuthorTotalLunchTimeTrends(commits, stats)
5375
+ // 新增:开发者累计加班时长(按日/周/月/年)
5376
+ drawAuthorTotalOvertimeTrends(commits, stats)
2452
5377
  computeAndRenderLatestOvertime(latestByDay)
2453
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)
2454
5394
  }
2455
5395
 
2456
5396
  // 抽屉关闭交互(按钮 + 点击遮罩)