wukong-gitlog-cli 1.0.39 → 1.0.41

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 +97 -0
  4. package/README.md +97 -172
  5. package/README.zh-CN.md +88 -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
@@ -0,0 +1,4324 @@
1
+ /* eslint-disable import/no-absolute-path */
2
+ /* eslint-disable no-use-before-define */
3
+ /* global echarts */
4
+ const formatDate = (d) => new Date(d).toLocaleString()
5
+
6
+ // 综合判断函数,考虑多种情况
7
+ function isEmptyObject(obj) {
8
+ // 1. 检查是否为对象
9
+ if (obj === null || typeof obj !== 'object') {
10
+ return false
11
+ }
12
+
13
+ // 2. 检查是否是空对象
14
+ return Object.keys(obj).length === 0
15
+ }
16
+
17
+ // 根据对象内容隐藏对应图表卡片
18
+ function hideElementByObj({ el, objectName }) {
19
+ const isEmpty = isEmptyObject(objectName)
20
+ if (isEmpty) {
21
+ const chartCard = el?.closest('.chart-card')
22
+ chartCard.style.display = 'none'
23
+ return true
24
+ }
25
+ return isEmpty
26
+ }
27
+
28
+ function filterByDate(commits) {
29
+ const start = document.getElementById('startDate')?.value
30
+ const end = document.getElementById('endDate')?.value
31
+
32
+ if (!start && !end) return commits
33
+
34
+ const startTime = start ? new Date(`${start}T00:00:00`).getTime() : -Infinity
35
+
36
+ const endTime = end ? new Date(`${end}T23:59:59`).getTime() : Infinity
37
+
38
+ return commits.filter((c) => {
39
+ const t = new Date(c.date).getTime()
40
+ return t >= startTime && t <= endTime
41
+ })
42
+ }
43
+
44
+ // ISO 周 key:YYYY-Www
45
+ function getIsoWeekKey(dStr) {
46
+ const d = new Date(dStr)
47
+ if (Number.isNaN(d.valueOf())) return null
48
+ const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
49
+ const dayNum = target.getUTCDay() || 7 // Sunday=0
50
+ target.setUTCDate(target.getUTCDate() + 4 - dayNum)
51
+ const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
52
+ const weekNo = Math.ceil(((target - yearStart) / 86400000 + 1) / 7)
53
+ const year = target.getUTCFullYear()
54
+ return `${year}-W${String(weekNo).padStart(2, '0')}`
55
+ }
56
+
57
+ function formatDateYMD(d) {
58
+ const yyyy = d.getFullYear()
59
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
60
+ const dd = String(d.getDate()).padStart(2, '0')
61
+ return `${yyyy}-${mm}-${dd}`
62
+ }
63
+
64
+ // 将 CLI 传入或 RC 中的 author 筛选信息格式化为可读字符串
65
+ function formatAuthorFilter(a) {
66
+ if (!a) return null
67
+
68
+ function toList(v) {
69
+ if (!v) return null
70
+ if (Array.isArray(v)) return v.map((s) => String(s).trim()).filter(Boolean)
71
+ return String(v)
72
+ .split(',')
73
+ .map((s) => s.trim())
74
+ .filter(Boolean)
75
+ }
76
+
77
+ if (typeof a === 'string') {
78
+ const list = toList(a)
79
+ return list && list.length ? list.join(', ') : String(a)
80
+ }
81
+
82
+ if (Array.isArray(a)) return a.join(', ')
83
+
84
+ if (typeof a === 'object') {
85
+ const include = toList(a.include)
86
+ const exclude = toList(a.exclude)
87
+ const parts = []
88
+ if (include && include.length) parts.push(`包含:${include.join(', ')}`)
89
+ if (exclude && exclude.length) parts.push(`排除:${exclude.join(', ')}`)
90
+ return parts.length ? parts.join(';') : null
91
+ }
92
+
93
+ return String(a)
94
+ }
95
+
96
+ function getISOWeekRange(isoYear, isoWeek) {
97
+ // 找到 ISO 年的第一个周一
98
+ // ISO 年的第 1 周包含 1 月 4 日
99
+ const simple = new Date(isoYear, 0, 4)
100
+ const dayOfWeek = simple.getDay() || 7 // Sunday=7
101
+ const firstMonday = new Date(simple)
102
+ firstMonday.setDate(simple.getDate() - dayOfWeek + 1)
103
+
104
+ // 计算目标周的周一
105
+ const monday = new Date(firstMonday)
106
+ monday.setDate(firstMonday.getDate() + (isoWeek - 1) * 7)
107
+
108
+ const sunday = new Date(monday)
109
+ sunday.setDate(monday.getDate() + 6)
110
+
111
+ return {
112
+ start: formatDateYMD(monday),
113
+ end: formatDateYMD(sunday)
114
+ }
115
+ }
116
+
117
+ async function loadData() {
118
+ // 定义加载函数,包装 import 以便添加错误处理
119
+ const safeImport = async (path, defaultValue) => {
120
+ try {
121
+ const module = await import(path)
122
+ return module.default || defaultValue
123
+ } catch (e) {
124
+ console.warn(`文件加载失败: ${path}`, e)
125
+ return defaultValue
126
+ }
127
+ }
128
+
129
+ // 并行加载基础数据(只加载快速的 analyze 生成的文件)
130
+ // 移除 overtime 文件加载,改为前端实时计算
131
+ const [commits, config, authorChanges, options] = await Promise.all([
132
+ safeImport('/data/commits.mjs', []),
133
+ safeImport('/data/config.mjs', {}),
134
+ safeImport('/data/author.changes.mjs', {}),
135
+ safeImport('/data/options.mjs', {})
136
+ ])
137
+
138
+ return { commits, config, authorChanges, options }
139
+ }
140
+
141
+ let commitsAll = []
142
+ let filtered = []
143
+ let page = 1
144
+ let pageSize = 10
145
+
146
+ function renderCommitsTablePage() {
147
+ const tbody = document.querySelector('#commitsTable tbody')
148
+ tbody.innerHTML = ''
149
+ const start = (page - 1) * pageSize
150
+ const end = start + pageSize
151
+ filtered.slice(start, end).forEach((c) => {
152
+ const tr = document.createElement('tr')
153
+ 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.isCherryPick}</td><td>${c.changed}</td>`
154
+ tbody.appendChild(tr)
155
+ })
156
+ document.getElementById('commitsTotal').textContent =
157
+ `共${filtered.length}条记录`
158
+ }
159
+
160
+ function updatePager() {
161
+ const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
162
+ if (page > totalPages) page = totalPages
163
+ const pageInfo = document.getElementById('pageInfo')
164
+ pageInfo.textContent = `${page} / ${totalPages}`
165
+ document.getElementById('prevPage').disabled = page <= 1
166
+ document.getElementById('nextPage').disabled = page >= totalPages
167
+ }
168
+
169
+ function applySearch() {
170
+ const q = document.getElementById('searchInput').value.trim().toLowerCase()
171
+
172
+ // ① 先做日期过滤
173
+ const base = filterByDate(commitsAll)
174
+
175
+ if (!q) {
176
+ filtered = base.slice()
177
+ } else {
178
+ filtered = base.filter((c) => {
179
+ const h = c.hash.toLowerCase()
180
+ const a = String(c.author || '').toLowerCase()
181
+ const e = String(c.email || '').toLowerCase()
182
+ const m = String(c.message || '').toLowerCase()
183
+ const d = formatDate(c.date).toLowerCase()
184
+ return (
185
+ h.includes(q) ||
186
+ a.includes(q) ||
187
+ e.includes(q) ||
188
+ m.includes(q) ||
189
+ d.includes(q)
190
+ )
191
+ })
192
+ }
193
+ page = 1
194
+ updatePager()
195
+ renderCommitsTablePage()
196
+ }
197
+
198
+ function initTableControls() {
199
+ document.getElementById('searchInput').addEventListener('input', applySearch)
200
+ document.getElementById('startDate')?.addEventListener('change', applySearch)
201
+ document.getElementById('endDate')?.addEventListener('change', applySearch)
202
+ document.getElementById('clearDate')?.addEventListener('click', () => {
203
+ document.getElementById('startDate').value = ''
204
+ document.getElementById('endDate').value = ''
205
+ applySearch()
206
+ })
207
+ document.getElementById('pageSize').addEventListener('change', (e) => {
208
+ pageSize = parseInt(e.target.value, 10) || 10
209
+ page = 1
210
+ updatePager()
211
+ renderCommitsTablePage()
212
+ })
213
+ document.getElementById('prevPage').addEventListener('click', () => {
214
+ if (page > 1) {
215
+ page -= 1
216
+ updatePager()
217
+ renderCommitsTablePage()
218
+ }
219
+ })
220
+ document.getElementById('nextPage').addEventListener('click', () => {
221
+ const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
222
+ if (page < totalPages) {
223
+ page += 1
224
+ updatePager()
225
+ renderCommitsTablePage()
226
+ }
227
+ })
228
+ }
229
+
230
+ function drawHourlyOvertime(stats, onHourClick) {
231
+ const el = document.getElementById('hourlyOvertimeChart')
232
+
233
+ const isEmpty = hideElementByObj({ el, objectName: stats })
234
+ if (isEmpty) {
235
+ return false
236
+ }
237
+ const chart = echarts.init(el)
238
+
239
+ // 显示所有提交数(不仅仅是加班)
240
+ const allCommits = Array(24).fill(0)
241
+ const labels = Array.from({ length: 24 }, (_, i) =>
242
+ String(i).padStart(2, '0')
243
+ )
244
+
245
+ // 从原始 commits 数据重新计算每小时的所有提交数
246
+ // 如果没有则使用后备逻辑
247
+ if (window.__allCommitsData && Array.isArray(window.__allCommitsData)) {
248
+ window.__allCommitsData.forEach((c) => {
249
+ const d = new Date(c.date)
250
+ if (!isNaN(d.getTime())) {
251
+ const h = d.getHours()
252
+ allCommits[h]++
253
+ }
254
+ })
255
+ }
256
+
257
+ // 颜色逻辑:根据时间段着色
258
+ function getColor(h) {
259
+ // 深夜(0-9 点)红色
260
+ if (h < stats.startHour) return '#b71c1c'
261
+ // 上班开始到午休开始:蓝色
262
+ if (h >= stats.startHour && h < stats.lunchStart) return '#1976d2'
263
+ // 午休时间:灰色
264
+ if (h >= stats.lunchStart && h < stats.lunchEnd) return '#888888'
265
+ // 午休结束到下班:蓝色
266
+ if (h >= stats.lunchEnd && h < stats.endHour) return '#1976d2'
267
+ // 下班后到晚上 19:00:橙色
268
+ if (h >= stats.endHour && h < 19) return '#fb8c00'
269
+ // 晚上 19:00 到深夜 21:00:深橙
270
+ if (h >= 19 && h < 21) return '#fb8c00'
271
+ // 深夜 21:00 后:红色
272
+ return '#d32f2f'
273
+ }
274
+
275
+ const data = allCommits.map((v, h) => ({
276
+ value: v,
277
+ itemStyle: { color: getColor(h) }
278
+ }))
279
+
280
+ // 计算百分比
281
+ const total = allCommits.reduce((sum, v) => sum + v, 0)
282
+ const percentData = allCommits.map((v) =>
283
+ total > 0 ? ((v / total) * 100).toFixed(1) : 0
284
+ )
285
+
286
+ chart.setOption({
287
+ tooltip: {
288
+ trigger: 'axis',
289
+ formatter(params) {
290
+ const p = params[0]
291
+ const h = parseInt(p.axisValue, 10)
292
+ const count = p.value
293
+ const percent = percentData[h]
294
+
295
+ // 判断时间段
296
+ let period = ''
297
+ if (h < stats.startHour) {
298
+ period = '深夜(需要休息)'
299
+ } else if (h >= stats.startHour && h < stats.lunchStart) {
300
+ period = '早上工作时间'
301
+ } else if (h >= stats.lunchStart && h < stats.lunchEnd) {
302
+ period = '午休时间'
303
+ } else if (h >= stats.lunchEnd && h < stats.endHour) {
304
+ period = '下午工作时间'
305
+ } else if (h >= stats.endHour && h < 19) {
306
+ period = '下班后(轻度加班)'
307
+ } else if (h >= 19 && h < 21) {
308
+ period = '晚间(中度加班)'
309
+ } else {
310
+ period = '深夜(严重加班)'
311
+ }
312
+
313
+ return `
314
+ 🕒 <b>${h}:00</b><br/>
315
+ 提交次数:<b>${count}</b><br/>
316
+ 占全天比例:<b>${percent}%</b><br/>
317
+ 时段:${period}
318
+ `
319
+ }
320
+ },
321
+
322
+ xAxis: {
323
+ type: 'category',
324
+ data: labels,
325
+ axisLabel: { color: '#555' }
326
+ },
327
+
328
+ yAxis: {
329
+ type: 'value',
330
+ min: 0,
331
+ axisLabel: { color: '#555' }
332
+ },
333
+
334
+ grid: { left: 40, right: 30, top: 20, bottom: 40 },
335
+
336
+ series: [
337
+ {
338
+ type: 'bar',
339
+ name: '每小时提交',
340
+ data,
341
+ barWidth: 18,
342
+
343
+ markPoint: {
344
+ symbol: 'pin',
345
+ symbolSize: 45,
346
+ itemStyle: { color: '#d32f2f' },
347
+ data: [
348
+ {
349
+ name: '最晚提交',
350
+ coord: [
351
+ String(stats.latestCommitHour).padStart(2, '0'),
352
+ allCommits[stats.latestCommitHour] || 0
353
+ ]
354
+ }
355
+ ]
356
+ },
357
+
358
+ markLine: {
359
+ symbol: 'none',
360
+ animation: true,
361
+ label: { color: '#888', formatter: '{b}' },
362
+ lineStyle: { type: 'dashed', color: '#aaa' },
363
+ data: [
364
+ {
365
+ name: '上班开始',
366
+ nameValue: String(stats.startHour).padStart(2, '0'),
367
+ xAxis: String(stats.startHour).padStart(2, '0')
368
+ },
369
+ {
370
+ name: '下班时间',
371
+ nameValue: String(stats.endHour).padStart(2, '0'),
372
+ xAxis: String(stats.endHour).padStart(2, '0')
373
+ },
374
+ {
375
+ name: '午休开始',
376
+ nameValue: String(stats.lunchStart).padStart(2, '0'),
377
+ xAxis: String(stats.lunchStart).padStart(2, '0')
378
+ },
379
+ {
380
+ name: '午休结束',
381
+ nameValue: String(stats.lunchEnd).padStart(2, '0'),
382
+ xAxis: String(stats.lunchEnd).padStart(2, '0')
383
+ }
384
+ ]
385
+ }
386
+ }
387
+ ]
388
+ })
389
+
390
+ // 点击事件(点击某小时 → 打开侧栏)
391
+ if (typeof onHourClick === 'function') {
392
+ chart.on('click', (p) => {
393
+ let hour = Number(p.name)
394
+ if (p.componentType === 'markLine') {
395
+ hour = Number(p.data.xAxis)
396
+ }
397
+ document.getElementById('dayDetailSidebar').classList.remove('show')
398
+ if (Number.isNaN(hour)) return
399
+
400
+ // 获取该小时的所有提交
401
+ const hourCommits = (window.__allCommitsData || []).filter((c) => {
402
+ const d = new Date(c.date)
403
+ return !isNaN(d.getTime()) && d.getHours() === hour
404
+ })
405
+
406
+ onHourClick(hour, hourCommits)
407
+ })
408
+ }
409
+
410
+ return chart
411
+ }
412
+
413
+ // 每小时加班分布
414
+ function showSideBarForHour({ hour, commitsOrCount, titleDrawer }) {
415
+ // 支持传入 number(仅次数)或 array(详细 commit 列表)
416
+ // 统一复用通用详情侧栏 DOM
417
+ const sidebar = document.getElementById('dayDetailSidebar')
418
+ const backdrop = document.getElementById('sidebarBackdrop')
419
+ const titleEl = document.getElementById('sidebarTitle')
420
+ const contentEl = document.getElementById('sidebarContent')
421
+ const drawerTitleEl = document.getElementById('sidebarDrawerTitle')
422
+
423
+ // 兼容未传入侧栏 DOM 的情况(优雅降级)
424
+ if (!sidebar || !titleEl || !contentEl) {
425
+ console.warn(
426
+ 'hourDetailSidebar DOM not found. Please add the HTML snippet.'
427
+ )
428
+ return
429
+ }
430
+
431
+ drawerTitleEl.innerHTML = titleDrawer || '🕒 小时详情'
432
+ titleEl.innerHTML = `🕒 ${String(hour).padStart(2, '0')}:00 - ${String(hour).padStart(2, '0')}:59`
433
+
434
+ // 如果只是 number,显示计数
435
+ if (typeof commitsOrCount === 'number') {
436
+ contentEl.innerHTML = `<div style="font-size:14px;">提交次数:<b>${commitsOrCount}</b></div>`
437
+ } else if (Array.isArray(commitsOrCount) && commitsOrCount.length === 0) {
438
+ contentEl.innerHTML = `<div style="font-size:14px;">当小时无提交记录</div>`
439
+ } else if (Array.isArray(commitsOrCount)) {
440
+ // commits 列表:展示作者/时间/消息(最多前 50 条,避免性能问题)
441
+ const commits = commitsOrCount.slice(0, 50)
442
+ contentEl.innerHTML = `<div class="sidebar-list">${commits
443
+ .map((c, index) => {
444
+ const author = c.author ?? c.name ?? 'unknown'
445
+ const time = c.date ?? c.time ?? ''
446
+ const msg = (c.message ?? c.msg ?? c.body ?? '').replace(/\n/g, ' ')
447
+ return `
448
+ <div class="sidebar-item">
449
+ <div class="sidebar-item-header">
450
+ <span class="author">${index + 1}. 👤 ${escapeHtml(author)}</span>
451
+ <span class="time">🕒 ${escapeHtml(time)}</span>
452
+ </div>
453
+ <div class="sidebar-item-message">${escapeHtml(msg)}</div>
454
+ </div>
455
+ `
456
+ })
457
+ .join('')}</div>`
458
+
459
+ if (commitsOrCount.length > 50) {
460
+ const more = commitsOrCount.length - 50
461
+ contentEl.innerHTML += `<div style="color:#888; padding:8px 0">另外 ${more} 条已省略</div>`
462
+ }
463
+ } else {
464
+ contentEl.innerHTML = `<div style="font-size:14px;">无可展示数据</div>`
465
+ }
466
+
467
+ // 打开侧栏 + 遮罩
468
+ sidebar.classList.add('show')
469
+ if (backdrop) backdrop.classList.add('show')
470
+ }
471
+
472
+ // 简单的 HTML 转义,防止 XSS 与布局断裂
473
+ function escapeHtml(str = '') {
474
+ return String(str)
475
+ .replaceAll('&', '&amp;')
476
+ .replaceAll('<', '&lt;')
477
+ .replaceAll('>', '&gt;')
478
+ .replaceAll('"', '&quot;')
479
+ .replaceAll("'", '&#39;')
480
+ }
481
+
482
+ /**
483
+ * 通用环形饼图(中心显示总数)
484
+ *
485
+ * @param {Object} options
486
+ * @param {string|HTMLElement} options.el DOM 或 id
487
+ * @param {Array} options.data [{ name, value }]
488
+ * @param {string} [options.title] 标题
489
+ * @param {string} [options.unit='次'] 单位
490
+ * @param {Array} [options.colors] 自定义颜色
491
+ * @param {string} [options.totalLabel='总计'] 中心文案
492
+ */
493
+ export function drawPieWithTotal({
494
+ el,
495
+ data = [],
496
+ title = '',
497
+ unit = '次',
498
+ colors = [],
499
+ totalLabel = '总计'
500
+ }) {
501
+ const dom = typeof el === 'string' ? document.getElementById(el) : el
502
+ const chart = echarts.init(dom)
503
+
504
+ const total = data.reduce((sum, item) => sum + (item.value || 0), 0)
505
+
506
+ const safeData = total === 0 ? [{ name: '暂无数据', value: 1 }] : data
507
+
508
+ chart.setOption({
509
+ color: colors.length ? colors : undefined,
510
+
511
+ title: title
512
+ ? {
513
+ text: title,
514
+ left: 'center',
515
+ top: 10
516
+ }
517
+ : undefined,
518
+
519
+ tooltip: {
520
+ trigger: 'item',
521
+ formatter: (params) => {
522
+ if (total === 0) return '暂无数据'
523
+ return `
524
+ ${params.name}<br/>
525
+ 数量:${params.value} ${unit}<br/>
526
+ 占比:${params.percent}%
527
+ `
528
+ }
529
+ },
530
+ legend: {
531
+ bottom: 0,
532
+ formatter: (name) => `${name}`
533
+ },
534
+ graphic:
535
+ total === 0
536
+ ? []
537
+ : [
538
+ {
539
+ type: 'text',
540
+ left: 'center',
541
+ top: '45%',
542
+ style: {
543
+ text: `${totalLabel}\n${total} ${unit}`,
544
+ textAlign: 'center',
545
+ fill: '#333',
546
+ fontSize: 14,
547
+ fontWeight: 600
548
+ }
549
+ }
550
+ ],
551
+
552
+ series: [
553
+ {
554
+ type: 'pie',
555
+ radius: ['40%', '60%'],
556
+ avoidLabelOverlap: false,
557
+ label: {
558
+ show: total !== 0,
559
+ formatter: `{b}\n{c} ${unit}`
560
+ },
561
+ emphasis: {
562
+ scale: true,
563
+ scaleSize: 8
564
+ },
565
+ data: safeData
566
+ }
567
+ ]
568
+ })
569
+
570
+ return chart
571
+ }
572
+
573
+ function drawOutsideVsInside(stats) {
574
+ const outside = stats.outsideWorkCount || 0
575
+ const total = stats.total || 0
576
+ const inside = Math.max(0, total - outside)
577
+
578
+ return drawPieWithTotal({
579
+ el: 'outsideVsInsideChart',
580
+ title: '提交时间分布',
581
+ unit: '次',
582
+ totalLabel: '总提交',
583
+ data: [
584
+ { name: '工作时间内', value: inside },
585
+ { name: '下班时间', value: outside }
586
+ ],
587
+ colors: ['#5470C6', '#EE6666']
588
+ })
589
+ }
590
+
591
+ function drawDailyTrend(commits, onDayClick) {
592
+ if (!Array.isArray(commits) || commits.length === 0) return null
593
+
594
+ // 聚合每日提交数量
595
+ const map = new Map()
596
+ commits.forEach((c) => {
597
+ const d = new Date(c.date).toISOString().slice(0, 10)
598
+ map.set(d, (map.get(d) || 0) + 1)
599
+ })
600
+
601
+ const labels = Array.from(map.keys()).sort()
602
+ const data = labels.map((l) => map.get(l))
603
+
604
+ const el = document.getElementById('dailyTrendChart')
605
+ const titleDrawer = el.getAttribute('data-title') || ''
606
+
607
+ // eslint-disable-next-line no-undef
608
+ const chart = echarts.init(el)
609
+
610
+ chart.setOption({
611
+ tooltip: {
612
+ trigger: 'axis',
613
+ formatter: (params) => {
614
+ const p = params?.[0]
615
+ if (!p) return ''
616
+
617
+ const date = p.axisValue
618
+ const count = p.data
619
+
620
+ // 分级说明
621
+ let level = '🟢 正常(≤5 次)'
622
+ if (count > 5 && count < 10) level = '🟠 较高频(6–10 次)'
623
+ if (count >= 10) level = '🔴 高频(≥10 次)'
624
+
625
+ return `
626
+ <div style="font-size:13px; line-height:1.5;">
627
+ <b>${date}</b><br/>
628
+ 提交次数:<b>${count}</b><br/>
629
+ 等级:${level}
630
+ </div>
631
+ `
632
+ }
633
+ },
634
+
635
+ xAxis: { type: 'category', data: labels },
636
+
637
+ yAxis: { type: 'value', min: 0 },
638
+
639
+ series: [
640
+ {
641
+ type: 'line',
642
+ name: '每日提交',
643
+ data,
644
+
645
+ smooth: true,
646
+
647
+ // ⭐ area 渐变背景
648
+ areaStyle: {
649
+ opacity: 0.2
650
+ },
651
+
652
+ // ⭐ 背景区间(低 / 中 / 高频)
653
+ markArea: {
654
+ data: [
655
+ [
656
+ { yAxis: 0 },
657
+ { yAxis: 5, itemStyle: { color: 'rgba(76, 175, 80, 0.12)' } } // 绿
658
+ ],
659
+ [
660
+ { yAxis: 5 },
661
+ { yAxis: 10, itemStyle: { color: 'rgba(251, 140, 0, 0.12)' } } // 橙
662
+ ],
663
+ [
664
+ { yAxis: 10 },
665
+ { yAxis: 50, itemStyle: { color: 'rgba(211, 47, 47, 0.12)' } } // 红
666
+ ]
667
+ ]
668
+ },
669
+
670
+ // ⭐ 阈值线
671
+ markLine: {
672
+ symbol: ['none', 'arrow'],
673
+ data: [
674
+ {
675
+ yAxis: 5,
676
+ lineStyle: { color: '#fb8c00', width: 2, type: 'dashed' },
677
+ label: { formatter: '5 次', color: '#fb8c00' }
678
+ },
679
+ {
680
+ yAxis: 10,
681
+ lineStyle: { color: '#d32f2f', width: 2, type: 'dashed' },
682
+ label: { formatter: '10 次', color: '#d32f2f' }
683
+ }
684
+ ]
685
+ }
686
+ }
687
+ ]
688
+ })
689
+
690
+ // 点击某一天,打开抽屉显示当日 commits
691
+ if (typeof onDayClick === 'function') {
692
+ chart.on('click', (params) => {
693
+ const idx = params.dataIndex
694
+ const date = labels[idx]
695
+ const count = data[idx]
696
+ const dayCommits = commits.filter(
697
+ (c) => new Date(c.date).toISOString().slice(0, 10) === date
698
+ )
699
+ // onDayClick(date, count, dayCommits)
700
+ onDayClick({
701
+ date,
702
+ count,
703
+ commits: dayCommits,
704
+ titleDrawer
705
+ })
706
+ })
707
+ }
708
+
709
+ return chart
710
+ }
711
+
712
+ function showSideBarForWeek({ period, weeklyItem, commits = [], titleDrawer }) {
713
+ // 统一复用通用详情侧栏 DOM
714
+ const sidebar = document.getElementById('dayDetailSidebar')
715
+ const backdrop = document.getElementById('sidebarBackdrop')
716
+ const titleEl = document.getElementById('sidebarTitle')
717
+ const contentEl = document.getElementById('sidebarContent')
718
+ const drawerTitleEl = document.getElementById('sidebarDrawerTitle')
719
+
720
+ titleEl.innerHTML = `📅 周期:<b>${period}</b>`
721
+ drawerTitleEl.innerHTML = titleDrawer || ''
722
+
723
+ let html = `
724
+ <div style="padding:6px 0;">
725
+ 加班次数:<b>${weeklyItem.outsideWorkCount}</b><br/>
726
+ 占比:<b>${(weeklyItem.outsideWorkRate * 100).toFixed(1)}%</b>
727
+ </div>
728
+ <hr/>
729
+ `
730
+
731
+ if (!commits.length) {
732
+ html += `<div style="padding:10px;color:#777;">该周无提交记录</div>`
733
+ } else {
734
+ html += `<div class="sidebar-list">${commits
735
+ .map((c, index) => {
736
+ const author = escapeHtml(c.author || 'unknown')
737
+ const time = escapeHtml(c.date || '')
738
+ const msg = escapeHtml((c.message || '').replace(/\n/g, ' '))
739
+ return `
740
+ <div class="sidebar-item">
741
+ <div class="sidebar-item-header">
742
+ <span class="author">${index + 1}👤 ${author}</span>
743
+ <span class="time">🕒 ${time}</span>
744
+ </div>
745
+ <div class="sidebar-item-message">${msg}</div>
746
+ </div>
747
+ `
748
+ })
749
+ .join('')}</div>`
750
+ }
751
+
752
+ contentEl.innerHTML = html
753
+ sidebar.classList.add('show')
754
+ if (backdrop) backdrop.classList.add('show')
755
+ }
756
+
757
+ function drawWeeklyTrend(weekly, commits, onWeekClick) {
758
+ const el = document.getElementById('weeklyTrendChart')
759
+ const isEmpty = hideElementByObj({ el, objectName: weekly })
760
+ if (isEmpty) {
761
+ return null
762
+ }
763
+ if (!Array.isArray(weekly) || weekly.length === 0) {
764
+ return null
765
+ }
766
+
767
+ const labels = weekly.map((w) => w.period)
768
+ const dataRate = weekly.map((w) => +(w.outsideWorkRate * 100).toFixed(1)) // %
769
+ const dataCount = weekly.map((w) => w.outsideWorkCount)
770
+
771
+ const titleDrawer = el.getAttribute('data-title') || ''
772
+
773
+ const chart = echarts.init(el)
774
+
775
+ chart.setOption({
776
+ tooltip: {
777
+ trigger: 'axis',
778
+ formatter: (params) => {
779
+ const pp = params[0]
780
+ const weekItem = weekly[pp.dataIndex]
781
+ const { start, end } = weekItem.range
782
+
783
+ const rate = params.find((p) => p.seriesName.includes('%'))?.data
784
+ const count = params.find((p) => p.seriesName.includes('次数'))?.data
785
+
786
+ // 加班等级
787
+ let level = '🟢 健康(<10%)'
788
+ if (rate >= 10 && rate < 20) level = '🟠 中度(10–20%)'
789
+ if (rate >= 20) level = '🔴 严重(≥20%)'
790
+
791
+ return `
792
+ <div style="font-size:13px; line-height:1.5;">
793
+ <b>${params[0].axisValue}</b><br/>
794
+ 📅 周区间:<b>${start} ~ ${end}</b><br/>
795
+ 加班占比:<b>${rate}%</b><br/>
796
+ 加班次数:${count} 次<br/>
797
+ 等级:${level}
798
+ </div>
799
+ `
800
+ }
801
+ },
802
+
803
+ legend: { top: 10 },
804
+
805
+ xAxis: { type: 'category', data: labels },
806
+ yAxis: [
807
+ { type: 'value', min: 0, max: 100, name: '占比(%)' },
808
+ { type: 'value', name: '次数', min: 0 }
809
+ ],
810
+
811
+ series: [
812
+ {
813
+ type: 'line',
814
+ name: '加班占比(%)',
815
+ data: dataRate,
816
+ markArea: {
817
+ data: [
818
+ [
819
+ { yAxis: 0 },
820
+ { yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } }
821
+ ],
822
+ [
823
+ { yAxis: 10 },
824
+ { yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } }
825
+ ],
826
+ [
827
+ { yAxis: 20 },
828
+ { yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } }
829
+ ]
830
+ ]
831
+ },
832
+ markLine: {
833
+ symbol: ['none', 'arrow'],
834
+ data: [
835
+ {
836
+ yAxis: 10,
837
+ lineStyle: { color: '#fb8c00', width: 2, type: 'dashed' },
838
+ label: { formatter: '10%', color: '#fb8c00' }
839
+ },
840
+ {
841
+ yAxis: 20,
842
+ lineStyle: { color: '#d32f2f', width: 2, type: 'dashed' },
843
+ label: { formatter: '20%', color: '#d32f2f' }
844
+ }
845
+ ]
846
+ }
847
+ },
848
+
849
+ {
850
+ type: 'line',
851
+ name: '加班次数',
852
+ data: dataCount,
853
+ yAxisIndex: 1,
854
+ smooth: true
855
+ }
856
+ ]
857
+ })
858
+
859
+ // ⭐ 点击事件:从 commits 过滤该周提交
860
+ chart.on('click', (p) => {
861
+ const idx = p.dataIndex
862
+ const w = weekly[idx]
863
+
864
+ const start = new Date(w.range.start)
865
+ const end = new Date(w.range.end)
866
+ end.setHours(23, 59, 59, 999) // 包含当天
867
+
868
+ const weeklyCommits = commits.filter((c) => {
869
+ const d = new Date(c.date)
870
+ return d >= start && d <= end
871
+ })
872
+
873
+ // 回调交给外面决定如何打开侧栏
874
+ if (typeof onWeekClick === 'function') {
875
+ // onWeekClick(w.period, w, weeklyCommits)
876
+ onWeekClick({
877
+ period: w.period,
878
+ weeklyItem: w,
879
+ commits: weeklyCommits,
880
+ titleDrawer
881
+ })
882
+ }
883
+ })
884
+
885
+ return chart
886
+ }
887
+
888
+ function drawMonthlyTrend(monthly, commits, onMonthClick) {
889
+ const el = document.getElementById('monthlyTrendChart')
890
+ const isEmpty = hideElementByObj({ el, objectName: monthly })
891
+ if (isEmpty) {
892
+ return null
893
+ }
894
+ if (!Array.isArray(monthly) || monthly.length === 0) return null
895
+
896
+ const labels = monthly.map((m) => m.period)
897
+ const dataRate = monthly.map((m) => +(m.outsideWorkRate * 100).toFixed(1)) // 0–100%
898
+
899
+ const titleDrawer = el.getAttribute('data-title') || ''
900
+ // eslint-disable-next-line no-undef
901
+ const chart = echarts.init(el)
902
+
903
+ chart.setOption({
904
+ tooltip: {
905
+ trigger: 'axis',
906
+ formatter: (params) => {
907
+ const p = params[0]
908
+ if (!p) return ''
909
+
910
+ const rate = p.data
911
+ let level = '🟢 健康(<10%)'
912
+ if (rate >= 10 && rate < 20) level = '🟠 中度(10–20%)'
913
+ if (rate >= 20) level = '🔴 严重(≥20%)'
914
+
915
+ return `
916
+ <div style="font-size:13px; line-height:1.5">
917
+ <b>${p.axisValue}</b><br/>
918
+ 加班占比:<b>${rate}%</b><br/>
919
+ 加班等级:${level}
920
+ </div>
921
+ `
922
+ }
923
+ },
924
+
925
+ xAxis: { type: 'category', data: labels },
926
+ yAxis: { type: 'value', min: 0, max: 100 },
927
+
928
+ series: [
929
+ {
930
+ type: 'line',
931
+ name: '加班占比(%)',
932
+ data: dataRate,
933
+
934
+ // ⭐ 区间背景(可配置)
935
+ markArea: {
936
+ data: [
937
+ // <10% 绿色轻度
938
+ [
939
+ { yAxis: 0 },
940
+ { yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } }
941
+ ],
942
+ // 10–20% 橙色中度
943
+ [
944
+ { yAxis: 10 },
945
+ { yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } }
946
+ ],
947
+ // ≥20% 红色严重
948
+ [
949
+ { yAxis: 20 },
950
+ { yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } }
951
+ ]
952
+ ]
953
+ },
954
+
955
+ // ⭐ 阈值线(同每日图风格)
956
+ markLine: {
957
+ symbol: ['none', 'arrow'],
958
+ data: [
959
+ {
960
+ yAxis: 10,
961
+ lineStyle: {
962
+ color: '#fb8c00',
963
+ width: 2,
964
+ type: 'dashed'
965
+ },
966
+ label: {
967
+ formatter: '10%',
968
+ color: '#fb8c00'
969
+ }
970
+ },
971
+ {
972
+ yAxis: 20,
973
+ lineStyle: {
974
+ color: '#d32f2f',
975
+ width: 2,
976
+ type: 'dashed'
977
+ },
978
+ label: {
979
+ formatter: '20%',
980
+ color: '#d32f2f'
981
+ }
982
+ }
983
+ ]
984
+ }
985
+ }
986
+ ]
987
+ })
988
+
989
+ // 点击某个月份,打开抽屉显示该月的所有 commits
990
+ if (typeof onMonthClick === 'function' && Array.isArray(commits)) {
991
+ chart.on('click', (params) => {
992
+ const idx = params.dataIndex
993
+ const ym = labels[idx] // 'YYYY-MM'
994
+ const monthCommits = commits.filter((c) => {
995
+ const d = new Date(c.date)
996
+ const m = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(
997
+ 2,
998
+ '0'
999
+ )}`
1000
+ return m === ym
1001
+ })
1002
+ // onMonthClick(ym, monthCommits.length, monthCommits)
1003
+ onMonthClick({
1004
+ date: ym,
1005
+ count: monthCommits.length,
1006
+ commits: monthCommits,
1007
+ titleDrawer
1008
+ })
1009
+ })
1010
+ }
1011
+
1012
+ return chart
1013
+ }
1014
+
1015
+ function drawLatestHourDaily(latestByDay, commits, onDayClick) {
1016
+ const el = document.getElementById('latestHourDailyChart')
1017
+ const isEmpty = hideElementByObj({ el, objectName: latestByDay })
1018
+ if (isEmpty) {
1019
+ return null
1020
+ }
1021
+ if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
1022
+
1023
+ const labels = latestByDay.map((d) => d.date)
1024
+
1025
+ const raw = latestByDay.map((d) =>
1026
+ typeof d.latestHourNormalized === 'number'
1027
+ ? d.latestHourNormalized
1028
+ : (d.latestHour ?? null)
1029
+ )
1030
+
1031
+ // 数据点颜色
1032
+ const data = raw.map((v) => ({
1033
+ value: v,
1034
+ itemStyle: {
1035
+ color:
1036
+ // eslint-disable-next-line no-nested-ternary
1037
+ v >= 20
1038
+ ? '#d32f2f' // 红
1039
+ : v >= 19
1040
+ ? '#fb8c00' // 橙
1041
+ : '#1976d2' // 蓝
1042
+ }
1043
+ }))
1044
+
1045
+ // 获取最大值,用于设置 yAxis 的 max
1046
+ const numericValues = raw.filter((v) => typeof v === 'number')
1047
+ const maxV = numericValues.length > 0 ? Math.max(...numericValues) : 0
1048
+
1049
+ const titleDrawer = el.getAttribute('data-title') || ''
1050
+
1051
+ // eslint-disable-next-line no-undef
1052
+ const chart = echarts.init(el)
1053
+
1054
+ chart.setOption({
1055
+ tooltip: {
1056
+ trigger: 'axis',
1057
+ formatter: (params) => {
1058
+ const p = Array.isArray(params) ? params[0] : params
1059
+ const v = p?.value != null ? Number(p.value) : null
1060
+ const endH = window.__overtimeEndHour || 18
1061
+
1062
+ if (v == null) {
1063
+ return `
1064
+ <div style="font-size:13px; line-height:1.5">
1065
+ <b>${p.axisValue}</b><br/>
1066
+ 无数据
1067
+ </div>
1068
+ `
1069
+ }
1070
+
1071
+ const overtime = Math.max(0, v - endH)
1072
+ const overtimeText = overtime.toFixed(2)
1073
+
1074
+ let level = '🟢 正常(无明显加班)'
1075
+ if (overtime >= 1 && overtime < 2) level = '🟠 中度加班(1–2h)'
1076
+ if (overtime >= 2) level = '🔴 严重加班(≥2h)'
1077
+
1078
+ return `
1079
+ <div style="font-size:13px; line-height:1.5">
1080
+ <b>${p.axisValue}</b><br/>
1081
+ 最晚提交时间:<b>${v.toFixed(2)} 点</b><br/>
1082
+ 超出下班:<b>${overtimeText} 小时</b><br/>
1083
+ 加班等级:${level}
1084
+ </div>
1085
+ `
1086
+ }
1087
+ },
1088
+ xAxis: { type: 'category', data: labels },
1089
+ yAxis: {
1090
+ type: 'value',
1091
+ min: 0,
1092
+ max: Math.max(26, Math.ceil(maxV + 1))
1093
+ },
1094
+ series: [
1095
+ {
1096
+ type: 'line',
1097
+ name: '每日最晚提交小时',
1098
+ data,
1099
+ // 让折线在 null 点之间连起来,避免视觉上“断裂”
1100
+ connectNulls: true,
1101
+ markLine: {
1102
+ symbol: ['none', 'arrow'],
1103
+ data: [
1104
+ // 20 小时线(橙色)
1105
+ {
1106
+ yAxis: 19,
1107
+ lineStyle: {
1108
+ color: '#fb8c00',
1109
+ width: 2,
1110
+ type: 'solid'
1111
+ },
1112
+ label: {
1113
+ formatter: '21h',
1114
+ color: '#fb8c00'
1115
+ }
1116
+ },
1117
+ // 21 小时线(红色)
1118
+ {
1119
+ yAxis: 20,
1120
+ lineStyle: {
1121
+ color: '#d32f2f',
1122
+ width: 2,
1123
+ type: 'solid'
1124
+ },
1125
+ label: {
1126
+ formatter: '24h',
1127
+ color: '#d32f2f'
1128
+ }
1129
+ }
1130
+ ]
1131
+ }
1132
+ }
1133
+ ]
1134
+ })
1135
+
1136
+ // 点击某一天的最晚提交时间点,打开抽屉显示该日 commits
1137
+ if (typeof onDayClick === 'function' && Array.isArray(commits)) {
1138
+ // 预聚合:按天收集 commits
1139
+ const dayCommitsMap = {}
1140
+ commits.forEach((c) => {
1141
+ const d = new Date(c.date).toISOString().slice(0, 10)
1142
+ if (!dayCommitsMap[d]) dayCommitsMap[d] = []
1143
+ dayCommitsMap[d].push(c)
1144
+ })
1145
+
1146
+ chart.on('click', (params) => {
1147
+ const idx = params.dataIndex
1148
+ const date = labels[idx]
1149
+ const list = dayCommitsMap[date] || []
1150
+ // onDayClick(date, list.length, list)
1151
+ onDayClick({
1152
+ date,
1153
+ count: list.length,
1154
+ commits: list,
1155
+ titleDrawer
1156
+ })
1157
+ })
1158
+ }
1159
+
1160
+ return chart
1161
+ }
1162
+
1163
+ function drawDailySeverity(latestByDay, commits, onDayClick) {
1164
+ const el = document.getElementById('dailySeverityChart')
1165
+ const isEmpty = hideElementByObj({ el, objectName: latestByDay })
1166
+ if (isEmpty) {
1167
+ return null
1168
+ }
1169
+ if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
1170
+
1171
+ const labels = latestByDay.map((d) => d.date)
1172
+ const endH = window.__overtimeEndHour || 18
1173
+
1174
+ const raw = latestByDay.map((d) =>
1175
+ typeof d.latestHourNormalized === 'number'
1176
+ ? d.latestHourNormalized
1177
+ : (d.latestHour ?? null)
1178
+ )
1179
+
1180
+ // 若某天 latestHourNormalized 为空,表示「没有下班后到次日上班前的提交」,
1181
+ // 这里按 0 小时加班处理,保证折线连续。
1182
+ const sev = raw.map((v) => (v == null ? 0 : Math.max(0, Number(v) - endH)))
1183
+
1184
+ const titleDrawer = el.getAttribute('data-title') || ''
1185
+
1186
+ // eslint-disable-next-line no-undef
1187
+ const chart = echarts.init(el)
1188
+
1189
+ chart.setOption({
1190
+ tooltip: {
1191
+ trigger: 'axis',
1192
+ formatter: (params) => {
1193
+ const p = params[0]
1194
+ if (!p) return ''
1195
+ const date = p.axisValue
1196
+ const overtime = p.data
1197
+ const rawHour = raw[p.dataIndex] // 原始 latestHour 或 latestHourNormalized
1198
+
1199
+ return `
1200
+ <div style="font-size:13px;">
1201
+ <b>${date}</b><br/>
1202
+ 下班后:<b>${overtime.toFixed(2)} 小时</b><br/>
1203
+ 原始最晚提交:${rawHour != null ? `${rawHour.toFixed(2)} 点` : '无'}<br/>
1204
+ 加班等级:${
1205
+ // eslint-disable-next-line no-nested-ternary
1206
+ overtime < 1
1207
+ ? '🟢 0–1 小时(轻度)'
1208
+ : overtime < 2
1209
+ ? '🟠 1–2 小时(中度)'
1210
+ : '🔴 ≥2 小时(严重)'
1211
+ }
1212
+ </div>
1213
+ `
1214
+ }
1215
+ },
1216
+
1217
+ xAxis: { type: 'category', data: labels },
1218
+ yAxis: { type: 'value', min: 0 },
1219
+
1220
+ series: [
1221
+ {
1222
+ type: 'line',
1223
+ name: '超过下班小时数',
1224
+ data: sev,
1225
+ // 连续显示 0 小时加班的日期,避免折线断开
1226
+ connectNulls: true,
1227
+
1228
+ // ⭐ 加班区域背景
1229
+ markArea: {
1230
+ data: [
1231
+ // 0–1h:透明
1232
+ [{ yAxis: 0 }, { yAxis: 1, itemStyle: { color: 'rgba(0,0,0,0)' } }],
1233
+ // 1–2h:半透明橙色
1234
+ [
1235
+ { yAxis: 1 },
1236
+ { yAxis: 2, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } } // #fb8c00
1237
+ ],
1238
+ // ≥2h:半透明红色
1239
+ [
1240
+ { yAxis: 2 },
1241
+ { yAxis: 10, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } } // #d32f2f
1242
+ ]
1243
+ ]
1244
+ },
1245
+
1246
+ // ⭐ 超时阈值标线
1247
+ markLine: {
1248
+ symbol: ['none', 'arrow'],
1249
+ data: [
1250
+ {
1251
+ yAxis: 1,
1252
+ lineStyle: {
1253
+ color: '#fb8c00',
1254
+ width: 2,
1255
+ type: 'dashed'
1256
+ },
1257
+ label: { formatter: '1h', color: '#fb8c00' }
1258
+ },
1259
+ {
1260
+ yAxis: 2,
1261
+ lineStyle: {
1262
+ color: '#d32f2f',
1263
+ width: 2,
1264
+ type: 'dashed'
1265
+ },
1266
+ label: { formatter: '2h', color: '#d32f2f' }
1267
+ }
1268
+ ]
1269
+ }
1270
+ }
1271
+ ]
1272
+ })
1273
+
1274
+ // 点击某一天的「超过下班小时数」点,打开抽屉显示该日 commits
1275
+ if (typeof onDayClick === 'function' && Array.isArray(commits)) {
1276
+ const dayCommitsMap = {}
1277
+ commits.forEach((c) => {
1278
+ const d = new Date(c.date).toISOString().slice(0, 10)
1279
+ if (!dayCommitsMap[d]) dayCommitsMap[d] = []
1280
+ dayCommitsMap[d].push(c)
1281
+ })
1282
+
1283
+ chart.on('click', (params) => {
1284
+ const idx = params.dataIndex
1285
+ const date = labels[idx]
1286
+ const list = dayCommitsMap[date] || []
1287
+ // onDayClick(date, list.length, list)
1288
+ onDayClick({
1289
+ date,
1290
+ count: list.length,
1291
+ commits: list,
1292
+ titleDrawer
1293
+ })
1294
+ })
1295
+ }
1296
+
1297
+ return chart
1298
+ }
1299
+
1300
+ /**
1301
+ * 绘制每日趋势(带加班严重度背景区间)并自动分析最累的日期
1302
+ * @param {Array} commits - 原始提交记录(包含 c.date)
1303
+ * @param {Function} onDayClick - 用户点击某一天时的回调 (date, count) => void
1304
+ */
1305
+ /**
1306
+ * 绘制每日趋势(含严重度背景区间、最累标记、tooltip 明细)
1307
+ */
1308
+ function drawDailyTrendSeverity(commits, weekly, onDayClick) {
1309
+ // ---------- 1. 聚合每日数据 ----------
1310
+ const dayMap = new Map()
1311
+ const dayCommitsDetail = {}
1312
+
1313
+ commits.forEach((c) => {
1314
+ const d = new Date(c.date).toISOString().slice(0, 10)
1315
+
1316
+ // 数量统计
1317
+ dayMap.set(d, (dayMap.get(d) || 0) + 1)
1318
+
1319
+ // 详细信息统计(用于 tooltip 显示)
1320
+ if (!dayCommitsDetail[d]) dayCommitsDetail[d] = []
1321
+ dayCommitsDetail[d].push({
1322
+ author: c.author,
1323
+ time: c.date,
1324
+ msg: c.message
1325
+ })
1326
+ })
1327
+
1328
+ const labels = Array.from(dayMap.keys()).sort()
1329
+ const data = labels.map((l) => dayMap.get(l))
1330
+
1331
+ // ---------- 2. 自动分析「最累的一天」 ----------
1332
+ const maxDailyCount = Math.max(...data)
1333
+ const maxDailyIndex = data.indexOf(maxDailyCount)
1334
+ const mostTiredDay = labels[maxDailyIndex]
1335
+
1336
+ document.getElementById('mostTiredDay').innerHTML =
1337
+ `🔥 最累的一天:<b>${mostTiredDay}</b>(${maxDailyCount} 次提交)`
1338
+
1339
+ // ---------- 3. 自动分析「最累的一周」 ----------
1340
+ let maxWeek = null
1341
+ if (Array.isArray(weekly) && weekly.length > 0) {
1342
+ maxWeek = weekly.reduce((a, b) =>
1343
+ a.outsideWorkCount > b.outsideWorkCount ? a : b
1344
+ )
1345
+ if (maxWeek) {
1346
+ document.getElementById('mostTiredWeek').innerHTML =
1347
+ `🔥 最累的一周:<b>${maxWeek.period}</b>(${maxWeek.outsideWorkCount} 次加班)`
1348
+ }
1349
+ }
1350
+
1351
+ // ---------- 4. 自动分析「最累的月份」 ----------
1352
+ const monthMap = new Map()
1353
+ commits.forEach((c) => {
1354
+ const d = new Date(c.date)
1355
+ const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
1356
+ monthMap.set(ym, (monthMap.get(ym) || 0) + 1)
1357
+ })
1358
+
1359
+ const mostTiredMonth = Array.from(monthMap.entries()).sort(
1360
+ (a, b) => b[1] - a[1]
1361
+ )[0]
1362
+
1363
+ document.getElementById('mostTiredMonth').innerHTML =
1364
+ `🔥 最累的月份:<b>${mostTiredMonth[0]}</b>(${mostTiredMonth[1]} 次提交)`
1365
+
1366
+ // ---------- 5. 背景严重度区块 ----------
1367
+ const markArea = {
1368
+ silent: true,
1369
+ itemStyle: { opacity: 0.15 },
1370
+ data: [
1371
+ [{ name: '0–5 次', yAxis: 0 }, { yAxis: 5 }],
1372
+ [
1373
+ { name: '5–10 次', yAxis: 5 },
1374
+ { yAxis: 10, itemStyle: { color: 'orange', opacity: 0.25 } }
1375
+ ],
1376
+ [
1377
+ { name: '10 次以上', yAxis: 10 },
1378
+ { yAxis: 999, itemStyle: { color: 'red', opacity: 0.25 } }
1379
+ ]
1380
+ ]
1381
+ }
1382
+
1383
+ // ---------- 6. 构造 tooltip ----------
1384
+ const tooltipFormatter = (params) => {
1385
+ const date = params?.[0].name
1386
+ const count = params?.[0].value
1387
+ const details = dayCommitsDetail[date] || []
1388
+
1389
+ let html = `📅 <b>${date}</b><br/>提交次数:${count}<br/><br/>`
1390
+
1391
+ details.slice(0, 5).forEach((d) => {
1392
+ html += `👤 ${d.author}<br/>🕒 ${d.time}<br/>💬 ${d.msg}<br/><br/>`
1393
+ })
1394
+
1395
+ if (details.length > 5) {
1396
+ html += `(其余 ${details.length - 5} 条已省略)`
1397
+ }
1398
+
1399
+ return html
1400
+ }
1401
+
1402
+ // ---------- 7. 绘图 ----------
1403
+ const el = document.getElementById('dailyTrendChartDog')
1404
+
1405
+ const titleDrawer = el.getAttribute('data-title') || ''
1406
+
1407
+ const chart = echarts.init(el)
1408
+
1409
+ chart.setOption({
1410
+ tooltip: {
1411
+ trigger: 'axis',
1412
+ formatter: tooltipFormatter,
1413
+ axisPointer: { type: 'shadow' }
1414
+ },
1415
+ xAxis: { type: 'category', data: labels },
1416
+ yAxis: { type: 'value', min: 0 },
1417
+ series: [
1418
+ {
1419
+ type: 'line',
1420
+ name: '每日提交',
1421
+ data,
1422
+ areaStyle: {},
1423
+ markArea,
1424
+ markPoint: {
1425
+ data: [
1426
+ {
1427
+ name: '最累的一天',
1428
+ coord: [mostTiredDay, maxDailyCount],
1429
+ value: maxDailyCount,
1430
+ symbolSize: 70,
1431
+ itemStyle: { color: '#ff4d4f' },
1432
+ label: { formatter: '🔥 最累' }
1433
+ }
1434
+ ]
1435
+ }
1436
+ }
1437
+ ]
1438
+ })
1439
+
1440
+ // ---------- 8. 点击事件 ----------
1441
+ if (typeof onDayClick === 'function') {
1442
+ chart.on('click', (params) => {
1443
+ if (params.componentType === 'series') {
1444
+ const date = labels[params.dataIndex]
1445
+ const count = data[params.dataIndex]
1446
+ // onDayClick(date, count, dayCommitsDetail[date])
1447
+ onDayClick({
1448
+ date,
1449
+ count,
1450
+ commits: dayCommitsDetail[date],
1451
+ titleDrawer
1452
+ })
1453
+ }
1454
+ })
1455
+ }
1456
+
1457
+ return {
1458
+ chart,
1459
+ analysis: {
1460
+ mostTiredDay,
1461
+ mostTiredMonth,
1462
+ mostTiredWeek: maxWeek
1463
+ }
1464
+ }
1465
+ }
1466
+
1467
+ function showDayDetailSidebar({ date, count, commits, titleDrawer }) {
1468
+ const sidebar = document.getElementById('dayDetailSidebar')
1469
+ const backdrop = document.getElementById('sidebarBackdrop')
1470
+ const title = document.getElementById('sidebarTitle')
1471
+ const content = document.getElementById('sidebarContent')
1472
+ const drawerTitleEl = document.getElementById('sidebarDrawerTitle')
1473
+
1474
+ title.innerHTML = `📅 ${date}(${count} 次提交)`
1475
+ drawerTitleEl.innerHTML = titleDrawer || ''
1476
+
1477
+ // 渲染详情
1478
+ content.innerHTML = commits
1479
+ .map(
1480
+ (c, index) => `
1481
+ <div class="sidebar-item">
1482
+ <div class="sidebar-item-header">
1483
+ <span class="author">${index + 1}👤 ${escapeHtml(c.author || 'unknown')}</span>
1484
+ <span class="time">🕒 ${escapeHtml(c.time || c.date || '')}</span>
1485
+ </div>
1486
+ <div class="sidebar-item-message">${escapeHtml(c.msg || c.message || '')}</div>
1487
+ </div>
1488
+ `
1489
+ )
1490
+ .join('')
1491
+
1492
+ sidebar.classList.add('show')
1493
+ if (backdrop) backdrop.classList.add('show')
1494
+ }
1495
+
1496
+ function renderKpi(stats) {
1497
+ const el = document.getElementById('kpiContent')
1498
+ if (!el || !stats) return
1499
+ const latest = stats.latestCommit
1500
+ const latestHour = stats.latestCommitHour
1501
+
1502
+ // 使用 cutoff + 上下班时间,重新在全部 commits 中计算「加班最晚一次提交」
1503
+ const cutoff = window.__overnightCutoff ?? 6
1504
+ const startHour =
1505
+ typeof stats.startHour === 'number' && stats.startHour >= 0
1506
+ ? stats.startHour
1507
+ : 9
1508
+ const endHour =
1509
+ typeof stats.endHour === 'number' && stats.endHour >= 0
1510
+ ? stats.endHour
1511
+ : (window.__overtimeEndHour ?? 18)
1512
+
1513
+ let latestOut = null
1514
+ let latestOutHour = null
1515
+ let maxSeverity = -1
1516
+
1517
+ if (Array.isArray(commitsAll) && commitsAll.length > 0) {
1518
+ commitsAll.forEach((c) => {
1519
+ const d = new Date(c.date)
1520
+ if (!d || Number.isNaN(d.valueOf())) return
1521
+ const h = d.getHours()
1522
+
1523
+ // 只看「当日下班后」以及「次日凌晨 cutoff 之前,且仍在上班前」的提交
1524
+ let sev = null
1525
+ if (h >= endHour && h < 24) {
1526
+ // 当晚:直接按 h - endHour 计算
1527
+ sev = h - endHour
1528
+ } else if (h >= 0 && h < cutoff && h < startHour) {
1529
+ // 次日凌晨:视作跨天,加上 24
1530
+ sev = 24 - endHour + h
1531
+ }
1532
+
1533
+ if (sev != null && sev >= 0 && sev > maxSeverity) {
1534
+ maxSeverity = sev
1535
+ latestOut = c
1536
+ latestOutHour = h
1537
+ }
1538
+ })
1539
+ }
1540
+
1541
+ // 若按 cutoff 没算出结果,则退回到原来的 stats.latestOutsideCommit
1542
+ if (!latestOut && stats.latestOutsideCommit) {
1543
+ latestOut = stats.latestOutsideCommit
1544
+ latestOutHour =
1545
+ stats.latestOutsideCommitHour ??
1546
+ (latestOut ? new Date(latestOut.date).getHours() : null)
1547
+ }
1548
+
1549
+ const htmlLatest = latest
1550
+ ? `<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>`
1551
+ : ``
1552
+
1553
+ // 采样区间展示(来自 config 或 serve 参数),同时支持筛选条件(author)
1554
+ const samplingSince = window.__samplingSince || null
1555
+ const samplingUntil = window.__samplingUntil || null
1556
+ const samplingAuthor = window.__samplingAuthor || null
1557
+ function formatSampling(dStr) {
1558
+ if (!dStr) return null
1559
+ const d = new Date(dStr)
1560
+ if (Number.isNaN(d.valueOf())) return escapeHtml(dStr)
1561
+ return formatDateYMD(d)
1562
+ }
1563
+ let samplingHtml = ''
1564
+ if (samplingSince && samplingUntil) {
1565
+ samplingHtml = `<div class="hr"></div><div class="sampling">采样区间:${formatSampling(samplingSince)} ~ ${formatSampling(samplingUntil)}${samplingAuthor ? ` (作者 ${escapeHtml(samplingAuthor)})` : ''}</div>`
1566
+ } else if (samplingSince) {
1567
+ samplingHtml = `<div class="hr"></div><div class="sampling">采样起始:${formatSampling(samplingSince)}(起)${samplingAuthor ? ` (作者 ${escapeHtml(samplingAuthor)})` : ''}</div>`
1568
+ } else if (samplingUntil) {
1569
+ samplingHtml = `<div class="hr"></div><div class="sampling">采样截止:${formatSampling(samplingUntil)}(止)${samplingAuthor ? ` (作者 ${escapeHtml(samplingAuthor)})` : ''}</div>`
1570
+ } else {
1571
+ samplingHtml = `<div class="hr"></div><div class="sampling">采样区间:全量提交${samplingAuthor ? ` (作者 ${escapeHtml(samplingAuthor)})` : ''}</div>`
1572
+ }
1573
+
1574
+ const html = [
1575
+ htmlLatest,
1576
+ `<div class="hr"></div>`,
1577
+ `<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>`,
1578
+ `<div class="hr"></div>`,
1579
+ `<div>次日归并窗口:凌晨 <b>${cutoff}</b> 点内归前一日</div>`,
1580
+ samplingHtml
1581
+ ].join('')
1582
+ el.innerHTML = html
1583
+
1584
+ // 同步显示在 header 侧边的采样信息(更醒目)
1585
+ const headerEl = document.getElementById('samplingInfo')
1586
+ if (headerEl) {
1587
+ const sSince = formatSampling(samplingSince)
1588
+ const sUntil = formatSampling(samplingUntil)
1589
+ const sAuthor = samplingAuthor
1590
+ ? ` (作者 ${escapeHtml(samplingAuthor)})`
1591
+ : ''
1592
+ const sText =
1593
+ sSince && sUntil
1594
+ ? `采样:${sSince} ~ ${sUntil}`
1595
+ : sSince
1596
+ ? `采样起:${sSince}`
1597
+ : sUntil
1598
+ ? `采样止:${sUntil}`
1599
+ : '采样:全量提交'
1600
+ headerEl.textContent = sText + sAuthor
1601
+ }
1602
+ }
1603
+
1604
+ // 1) 按小时分组(例:commits 为原始提交数组)
1605
+ function groupCommitsByHour(commits) {
1606
+ const byHour = Array.from({ length: 24 }, () => [])
1607
+ commits.forEach((c) => {
1608
+ // 解析 commit 的本地小时(考虑时区已有 '+0800' 等)
1609
+ const d = new Date(c.date)
1610
+ const h = d.getHours() // 若数据已为 UTC,请按需求调整
1611
+ byHour[h].push(c)
1612
+ })
1613
+ return byHour
1614
+ }
1615
+
1616
+ // 基于 latestByDay + cutoff/endHour 统计「最晚加班的一天 / 一周 / 一月」
1617
+ function computeAndRenderLatestOvertime(latestByDay) {
1618
+ if (!Array.isArray(latestByDay) || latestByDay.length === 0) return
1619
+
1620
+ const endH = window.__overtimeEndHour || 18
1621
+
1622
+ // 每天的 latestHourNormalized → 超出下班的小时数
1623
+ const dailyOvertime = latestByDay
1624
+ .map((d) => {
1625
+ let v = null
1626
+ if (typeof d.latestHourNormalized === 'number') {
1627
+ v = d.latestHourNormalized
1628
+ } else if (typeof d.latestHour === 'number') {
1629
+ v = d.latestHour
1630
+ }
1631
+ if (v == null) return null
1632
+ const overtime = Math.max(0, Number(v) - endH)
1633
+ return { date: d.date, overtime, raw: v }
1634
+ })
1635
+ .filter(Boolean)
1636
+
1637
+ if (!dailyOvertime.length) return
1638
+
1639
+ // 1) 最晚加班的一天(超出下班小时数最大,若相同取日期更晚)
1640
+ const dailySorted = [...dailyOvertime].sort((a, b) => {
1641
+ if (b.overtime !== a.overtime) return b.overtime - a.overtime
1642
+ return new Date(b.date) - new Date(a.date)
1643
+ })
1644
+ const worstDay = dailySorted[0]
1645
+ const dayEl = document.getElementById('latestOvertimeDay')
1646
+ if (dayEl) {
1647
+ dayEl.innerHTML = `⏰ 最晚加班的一天:<b>${worstDay.date}</b>(超过下班 <b>${worstDay.overtime.toFixed(
1648
+ 2
1649
+ )}</b> 小时,逻辑时间约 ${worstDay.raw.toFixed(2)} 点)`
1650
+ }
1651
+
1652
+ // 2) 按周聚合:每周取「该周内任意一天的最大加班时长」
1653
+ const weekMap = new Map()
1654
+ dailyOvertime.forEach((d) => {
1655
+ const key = getIsoWeekKey(d.date)
1656
+ if (!key) return
1657
+ const cur = weekMap.get(key)
1658
+ if (!cur || d.overtime > cur.overtime) {
1659
+ weekMap.set(key, d)
1660
+ }
1661
+ })
1662
+
1663
+ if (weekMap.size) {
1664
+ const weeks = Array.from(weekMap.entries()).sort((a, b) => {
1665
+ if (b[1].overtime !== a[1].overtime) return b[1].overtime - a[1].overtime
1666
+ return new Date(b[1].date) - new Date(a[1].date)
1667
+ })
1668
+ const [weekKey, weekInfo] = weeks[0]
1669
+ const weekEl = document.getElementById('latestOvertimeWeek')
1670
+ if (weekEl) {
1671
+ weekEl.innerHTML = `⏰ 最晚加班的一周:<b>${weekKey}</b>(代表日期 ${weekInfo.date},超过下班 <b>${weekInfo.overtime.toFixed(
1672
+ 2
1673
+ )}</b> 小时)`
1674
+ }
1675
+ }
1676
+
1677
+ // 3) 按月聚合:每月取「该月任意一天的最大加班时长」
1678
+ const monthMap = new Map()
1679
+ dailyOvertime.forEach((d) => {
1680
+ const dt = new Date(d.date)
1681
+ if (Number.isNaN(dt.valueOf())) return
1682
+ const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(
1683
+ 2,
1684
+ '0'
1685
+ )}`
1686
+ const cur = monthMap.get(key)
1687
+ if (!cur || d.overtime > cur.overtime) {
1688
+ monthMap.set(key, d)
1689
+ }
1690
+ })
1691
+
1692
+ if (monthMap.size) {
1693
+ const months = Array.from(monthMap.entries()).sort((a, b) => {
1694
+ if (b[1].overtime !== a[1].overtime) return b[1].overtime - a[1].overtime
1695
+ return new Date(b[1].date) - new Date(a[1].date)
1696
+ })
1697
+ const [monthKey, monthInfo] = months[0]
1698
+ const monthEl = document.getElementById('latestOvertimeMonth')
1699
+ if (monthEl) {
1700
+ monthEl.innerHTML = `⏰ 最晚加班的月份:<b>${monthKey}</b>(代表日期 ${monthInfo.date},超过下班 <b>${monthInfo.overtime.toFixed(
1701
+ 2
1702
+ )}</b> 小时)`
1703
+ }
1704
+ }
1705
+ }
1706
+
1707
+ function buildDataset(stats, type) {
1708
+ const dataMap = stats[type] // { author: { period: changed } }
1709
+
1710
+ const authors = Object.keys(dataMap)
1711
+ const allPeriods = Array.from(
1712
+ new Set(authors.flatMap((a) => Object.keys(dataMap[a])))
1713
+ ).sort()
1714
+
1715
+ const series = authors.map((a) => ({
1716
+ name: a,
1717
+ type: 'line',
1718
+ smooth: true,
1719
+ data: allPeriods.map((p) => dataMap[a][p] || 0)
1720
+ }))
1721
+
1722
+ return { authors, allPeriods, series }
1723
+ }
1724
+
1725
+ const drawChangeTrends = (stats) => {
1726
+ const el = document.getElementById('chartAuthorChanges')
1727
+ if (!el) return null
1728
+ const chart = echarts.init(el)
1729
+
1730
+ function render(type) {
1731
+ const { authors, allPeriods, series } = buildDataset(stats, type)
1732
+ const ds = { authors, allPeriods, series }
1733
+ ds.rangeMap = {}
1734
+
1735
+ for (const period of ds.allPeriods) {
1736
+ if (period.includes('-W')) {
1737
+ const [yy, ww] = period.split('-W')
1738
+ ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
1739
+ }
1740
+ }
1741
+ chart.setOption({
1742
+ // tooltip: { trigger: 'axis' },
1743
+ tooltip: {
1744
+ trigger: 'axis',
1745
+ formatter(params) {
1746
+ if (!params || !params.length) return ''
1747
+
1748
+ const p = params[0]
1749
+ const label = p.axisValue
1750
+ const isWeekly = type === 'weekly'
1751
+
1752
+ let extra = ''
1753
+ if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
1754
+ const { start, end } = ds.rangeMap[label]
1755
+ // extra = `<div style="margin-top:4px;color:#999;font-size:12px">
1756
+ // 周区间:${start} ~ ${end}
1757
+ // </div>`
1758
+ extra = ''
1759
+ }
1760
+
1761
+ const lines = params
1762
+ .filter((i) => i.data > 0)
1763
+ .sort(
1764
+ (a, b) =>
1765
+ (b.data || 0) - (a.data || 0) ||
1766
+ String(a.seriesName).localeCompare(String(b.seriesName))
1767
+ )
1768
+ .map(
1769
+ (item) => `${item.marker}${item.seriesName}: ${item.data} 行变更`
1770
+ )
1771
+ .join('<br/>')
1772
+
1773
+ return `
1774
+ <div>${label}</div>
1775
+ ${extra}
1776
+ ${lines}
1777
+ `
1778
+ }
1779
+ },
1780
+ legend: { data: authors },
1781
+ xAxis: { type: 'category', data: allPeriods },
1782
+ yAxis: { type: 'value' },
1783
+ series
1784
+ })
1785
+ }
1786
+
1787
+ // 初次渲染:日
1788
+ render('daily')
1789
+
1790
+ // tabs 切换
1791
+ const tabs = document.querySelectorAll('#tabs button')
1792
+ tabs.forEach((btnEl) => {
1793
+ btnEl.addEventListener('click', () => {
1794
+ tabs.forEach((b) => b.classList.remove('active'))
1795
+ btnEl.classList.add('active')
1796
+ render(btnEl.dataset.type)
1797
+ })
1798
+ })
1799
+
1800
+ // 点击事件:点击某个作者在某个周期的点,打开侧栏显示该作者在该周期的 commits
1801
+ chart.on('click', (p) => {
1802
+ try {
1803
+ if (!p || p.componentType !== 'series') return
1804
+ const label = p.axisValue || p.name
1805
+ const author = p.seriesName
1806
+ if (!label || !author) return
1807
+ const type =
1808
+ document.querySelector('#tabs button.active')?.dataset.type || 'daily'
1809
+
1810
+ const filteredCommits = (
1811
+ Array.isArray(commitsAll) ? commitsAll : []
1812
+ ).filter((c) => {
1813
+ const a = c.author || 'unknown'
1814
+ if (a !== author) return false
1815
+ const d = new Date(c.date)
1816
+ if (Number.isNaN(d.valueOf())) return false
1817
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
1818
+ if (type === 'weekly') {
1819
+ if (!label.includes('-W')) return false
1820
+ const [yy, ww] = label.split('-W')
1821
+ const range = getISOWeekRange(Number(yy), Number(ww))
1822
+ const day = d.toISOString().slice(0, 10)
1823
+ return day >= range.start && day <= range.end
1824
+ }
1825
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
1826
+ return month === label
1827
+ })
1828
+
1829
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
1830
+
1831
+ if (type === 'weekly') {
1832
+ const weeklyItem = {
1833
+ outsideWorkCount: filteredCommits.length,
1834
+ outsideWorkRate: 0
1835
+ }
1836
+ showSideBarForWeek({
1837
+ period: label,
1838
+ weeklyItem,
1839
+ commits: filteredCommits,
1840
+ titleDrawer: `${author} 变更量 ${type} 详情`
1841
+ })
1842
+ } else {
1843
+ showDayDetailSidebar({
1844
+ date: label,
1845
+ count: filteredCommits.length,
1846
+ commits: filteredCommits,
1847
+ titleDrawer: `${author} 变更量 ${type} 详情`
1848
+ })
1849
+ }
1850
+ } catch (err) {
1851
+ console.warn('Change chart click handler error', err)
1852
+ }
1853
+ })
1854
+
1855
+ return chart
1856
+ }
1857
+
1858
+ // ========= 开发者加班趋势(基于 commits 现场计算) =========
1859
+ function buildAuthorOvertimeDataset(commits, type, startHour, endHour, cutoff) {
1860
+ const byAuthor = new Map()
1861
+ const periods = new Set()
1862
+
1863
+ commits.forEach((c) => {
1864
+ const d = new Date(c.date)
1865
+ if (Number.isNaN(d.valueOf())) return
1866
+ const h = d.getHours()
1867
+ const isOvertime =
1868
+ (h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
1869
+ if (!isOvertime) return
1870
+
1871
+ let key
1872
+ if (type === 'daily') {
1873
+ key = d.toISOString().slice(0, 10)
1874
+ } else if (type === 'weekly') {
1875
+ key = getIsoWeekKey(d.toISOString().slice(0, 10))
1876
+ } else {
1877
+ key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
1878
+ }
1879
+ if (!key) return
1880
+ periods.add(key)
1881
+
1882
+ const author = c.author || 'unknown'
1883
+ if (!byAuthor.has(author)) byAuthor.set(author, {})
1884
+ const obj = byAuthor.get(author)
1885
+ obj[key] = (obj[key] || 0) + 1
1886
+ })
1887
+
1888
+ const allPeriods = Array.from(periods).sort()
1889
+ const authors = Array.from(byAuthor.keys()).sort()
1890
+ const series = authors.map((a) => ({
1891
+ name: a,
1892
+ type: 'line',
1893
+ smooth: true,
1894
+ data: allPeriods.map((p) => byAuthor.get(a)[p] || 0)
1895
+ }))
1896
+ return { authors, allPeriods, series }
1897
+ }
1898
+
1899
+ function drawAuthorOvertimeTrends(commits, stats) {
1900
+ const el = document.getElementById('chartAuthorOvertime')
1901
+ if (!el) return null
1902
+ const chart = echarts.init(el)
1903
+
1904
+ const startHour =
1905
+ typeof stats.startHour === 'number' && stats.startHour >= 0
1906
+ ? stats.startHour
1907
+ : 9
1908
+ const endHour =
1909
+ typeof stats.endHour === 'number' && stats.endHour >= 0
1910
+ ? stats.endHour
1911
+ : window.__overtimeEndHour || 18
1912
+ const cutoff = window.__overnightCutoff ?? 6
1913
+
1914
+ function render(type) {
1915
+ const ds = buildAuthorOvertimeDataset(
1916
+ commits,
1917
+ type,
1918
+ startHour,
1919
+ endHour,
1920
+ cutoff
1921
+ )
1922
+ ds.rangeMap = {}
1923
+
1924
+ for (const period of ds.allPeriods) {
1925
+ if (period.includes('-W')) {
1926
+ const [yy, ww] = period.split('-W')
1927
+ ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
1928
+ }
1929
+ }
1930
+ chart.setOption({
1931
+ tooltip: {
1932
+ trigger: 'axis',
1933
+ formatter(params) {
1934
+ if (!params || !params.length) return ''
1935
+
1936
+ const p = params[0]
1937
+ const label = p.axisValue
1938
+ const isWeekly = type === 'weekly'
1939
+
1940
+ let extra = ''
1941
+ if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
1942
+ const { start, end } = ds.rangeMap[label]
1943
+ extra = `<div style="margin-top:4px;color:#999;font-size:12px">
1944
+ 周区间:${start} ~ ${end}
1945
+ </div>`
1946
+ }
1947
+
1948
+ const lines = params
1949
+ .filter((i) => i.data > 0)
1950
+ .sort(
1951
+ (a, b) =>
1952
+ (b.data || 0) - (a.data || 0) ||
1953
+ String(a.seriesName).localeCompare(String(b.seriesName))
1954
+ )
1955
+ .map(
1956
+ (item) => `${item.marker}${item.seriesName}: ${item.data} 次提交`
1957
+ )
1958
+ .join('<br/>')
1959
+
1960
+ return `
1961
+ <div>${label}</div>
1962
+ ${extra}
1963
+ ${lines}
1964
+ `
1965
+ }
1966
+ },
1967
+ legend: { data: ds.authors },
1968
+ xAxis: { type: 'category', data: ds.allPeriods },
1969
+ // 把 y 轴名称改为提交数
1970
+ yAxis: { type: 'value', name: '提交数 (次)' },
1971
+
1972
+ series: ds.series
1973
+ })
1974
+ }
1975
+
1976
+ // 初始按日
1977
+ render('daily')
1978
+
1979
+ // tabs 切换
1980
+ const tabs = document.querySelectorAll('#tabsOvertime button')
1981
+ tabs.forEach((btnEl) => {
1982
+ btnEl.addEventListener('click', () => {
1983
+ tabs.forEach((b) => b.classList.remove('active'))
1984
+ btnEl.classList.add('active')
1985
+ render(btnEl.dataset.type)
1986
+ })
1987
+ })
1988
+
1989
+ // 输出本周风险与加班时长排行
1990
+ renderWeeklyRiskSummary(commits, { startHour, endHour, cutoff })
1991
+ renderMonthlyRiskSummary(commits, { startHour, endHour, cutoff })
1992
+ // 新增:本周/本月加班时长排名(显示所有作者总时长,前三名带图标,状元标注“夜魔侠”)
1993
+ renderWeeklyDurationRankSummary(commits, { startHour, endHour, cutoff })
1994
+ renderWeeklyDurationRiskSummary(commits, { startHour, endHour, cutoff })
1995
+ renderMonthlyDurationRankSummary(commits, { startHour, endHour, cutoff })
1996
+ renderMonthlyDurationRiskSummary(commits, { startHour, endHour, cutoff })
1997
+ renderRolling30DurationRiskSummary(commits, { startHour, endHour, cutoff })
1998
+
1999
+ // 点击事件:点击某个作者在某周期的点,打开侧栏显示该作者在该周期内的下班后提交(加班)明细
2000
+ chart.on('click', (p) => {
2001
+ try {
2002
+ if (!p || p.componentType !== 'series') return
2003
+ const label = p.axisValue || p.name
2004
+ const author = p.seriesName
2005
+ if (!label || !author) return
2006
+ const type =
2007
+ document.querySelector('#tabsOvertime button.active')?.dataset.type ||
2008
+ 'daily'
2009
+
2010
+ const filteredCommits = commits.filter((c) => {
2011
+ const a = c.author || 'unknown'
2012
+ if (a !== author) return false
2013
+ const d = new Date(c.date)
2014
+ if (Number.isNaN(d.valueOf())) return false
2015
+ const h = d.getHours()
2016
+ const isOT =
2017
+ (h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
2018
+ if (!isOT) return false
2019
+
2020
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
2021
+ if (type === 'weekly') {
2022
+ if (!label.includes('-W')) return false
2023
+ const [yy, ww] = label.split('-W')
2024
+ const range = getISOWeekRange(Number(yy), Number(ww))
2025
+ const day = d.toISOString().slice(0, 10)
2026
+ return day >= range.start && day <= range.end
2027
+ }
2028
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2029
+ return month === label
2030
+ })
2031
+
2032
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
2033
+
2034
+ if (type === 'weekly') {
2035
+ const weeklyItem = {
2036
+ outsideWorkCount: filteredCommits.length,
2037
+ outsideWorkRate: 0
2038
+ }
2039
+ showSideBarForWeek({
2040
+ period: label,
2041
+ weeklyItem,
2042
+ commits: filteredCommits,
2043
+ titleDrawer: `${author} 加班本周详情`
2044
+ })
2045
+ } else {
2046
+ showDayDetailSidebar({
2047
+ date: label,
2048
+ count: filteredCommits.length,
2049
+ commits: filteredCommits,
2050
+ titleDrawer: `${author} 加班 ${type} 详情`
2051
+ })
2052
+ }
2053
+ } catch (err) {
2054
+ console.warn('Overtime chart click handler error', err)
2055
+ }
2056
+ })
2057
+
2058
+ return chart
2059
+ }
2060
+
2061
+ function renderWeeklyRiskSummary(
2062
+ commits,
2063
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2064
+ ) {
2065
+ const box = document.getElementById('weeklyRiskSummary')
2066
+ if (!box) return
2067
+
2068
+ // 获取当前周与上一周 key
2069
+ const now = new Date()
2070
+ const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
2071
+ const prev = new Date(now)
2072
+ prev.setDate(prev.getDate() - 7)
2073
+ const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
2074
+
2075
+ // 统计:每周 -> author -> count;同时统计每周日期集合
2076
+ const weekAuthor = new Map()
2077
+ const weekDatesByAuthor = new Map() // week -> author -> Set(date)
2078
+
2079
+ commits.forEach((c) => {
2080
+ const d = new Date(c.date)
2081
+ if (Number.isNaN(d.valueOf())) return
2082
+ const h = d.getHours()
2083
+ const isOT =
2084
+ (h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
2085
+ if (!isOT) return
2086
+
2087
+ const key = getIsoWeekKey(d.toISOString().slice(0, 10))
2088
+ if (!key) return
2089
+ const author = c.author || 'unknown'
2090
+
2091
+ if (!weekAuthor.has(key)) weekAuthor.set(key, new Map())
2092
+ const m = weekAuthor.get(key)
2093
+ m.set(author, (m.get(author) || 0) + 1)
2094
+
2095
+ if (!weekDatesByAuthor.has(key)) weekDatesByAuthor.set(key, new Map())
2096
+ const dMap = weekDatesByAuthor.get(key)
2097
+ if (!dMap.has(author)) dMap.set(author, new Set())
2098
+ dMap.get(author).add(d.toISOString().slice(0, 10))
2099
+ })
2100
+
2101
+ const curMap = weekAuthor.get(curKey) || new Map()
2102
+ const prevMap = weekAuthor.get(prevKey) || new Map()
2103
+ const curTotal = Array.from(curMap.values()).reduce((a, b) => a + b, 0)
2104
+ const prevTotal = Array.from(prevMap.values()).reduce((a, b) => a + b, 0)
2105
+ const delta =
2106
+ prevTotal > 0
2107
+ ? Math.round(((curTotal - prevTotal) / prevTotal) * 100)
2108
+ : null
2109
+
2110
+ // 找当前周最“活跃”的人(加班提交最多),并统计他加班的自然日数
2111
+ let topAuthor = null
2112
+ let topCount = -1
2113
+ curMap.forEach((v, k) => {
2114
+ if (v > topCount) {
2115
+ topCount = v
2116
+ topAuthor = k
2117
+ }
2118
+ })
2119
+ const curDatesMap = weekDatesByAuthor.get(curKey) || new Map()
2120
+ const topDays =
2121
+ topAuthor && curDatesMap.get(topAuthor)
2122
+ ? curDatesMap.get(topAuthor).size
2123
+ : 0
2124
+
2125
+ // 文案
2126
+ const lines = []
2127
+ lines.push('【本周风险总结】')
2128
+
2129
+ if (curTotal === 0) {
2130
+ lines.push('团队本周暂无加班提交。')
2131
+ } else if (delta === null) {
2132
+ lines.push(`团队本周加班提交 ${curTotal} 次。`)
2133
+ } else {
2134
+ const trend = delta >= 0 ? '上升' : '下降'
2135
+ lines.push(`团队加班${trend} ${Math.abs(delta)}%(vs 上周)。`)
2136
+ }
2137
+
2138
+ if (topAuthor && curTotal > 0) {
2139
+ const pct = Math.round((topCount / curTotal) * 100)
2140
+ lines.push(
2141
+ `${topAuthor} 夜间活跃度 ${pct}%,${topDays} 天出现下班后提交(${endHour}:00 后或次日 ${cutoff}:00 前)。`
2142
+ )
2143
+ }
2144
+
2145
+ // ---------- 追加:本周“最晚加班风险”分析(合并自原 renderLatestRiskSummary) ----------
2146
+ const weekMax = new Map()
2147
+ commits.forEach((c) => {
2148
+ const d = new Date(c.date)
2149
+ if (Number.isNaN(d.valueOf())) return
2150
+ const h = d.getHours()
2151
+ let overtime = null
2152
+ if (h >= endHour && h < 24) overtime = h - endHour
2153
+ else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
2154
+ if (overtime == null) return
2155
+
2156
+ const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
2157
+ if (!wKey) return
2158
+ if (!weekMax.has(wKey)) weekMax.set(wKey, new Map())
2159
+ const m = weekMax.get(wKey)
2160
+ const author = c.author || 'unknown'
2161
+ const cur = m.get(author)
2162
+ if (!cur || overtime > cur.max) {
2163
+ m.set(author, { max: overtime, date: d.toISOString().slice(0, 10) })
2164
+ }
2165
+ })
2166
+
2167
+ const curMaxMap = weekMax.get(curKey) || new Map()
2168
+ const prevMaxMap = weekMax.get(prevKey) || new Map()
2169
+ let topAuthorLatest = null
2170
+ let topLatest = { max: -1, date: null }
2171
+ curMaxMap.forEach((v, k) => {
2172
+ if (v.max > topLatest.max) {
2173
+ topLatest = v
2174
+ topAuthorLatest = k
2175
+ }
2176
+ })
2177
+ let prevMaxLatest = -1
2178
+ prevMaxMap.forEach((v) => {
2179
+ if (v.max > prevMaxLatest) prevMaxLatest = v.max
2180
+ })
2181
+
2182
+ const latestLines = []
2183
+ latestLines.push('【本周最晚加班风险】')
2184
+
2185
+ if (topLatest.max < 0) {
2186
+ latestLines.push('本周尚无下班后/凌晨提交,未发现明显风险。')
2187
+ } else {
2188
+ let trend2 = '暂无上周对比'
2189
+ if (prevMaxLatest >= 0) {
2190
+ if (topLatest.max > prevMaxLatest) trend2 = '较上周更晚'
2191
+ else if (topLatest.max < prevMaxLatest) trend2 = '较上周提前'
2192
+ else trend2 = '与上周持平'
2193
+ }
2194
+ latestLines.push(
2195
+ `${topAuthorLatest} 本周最晚超出下班 ${topLatest.max.toFixed(2)} 小时(${topLatest.date}),${trend2}。`
2196
+ )
2197
+ if (topLatest.max >= 2) {
2198
+ latestLines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
2199
+ } else if (topLatest.max >= 1) {
2200
+ latestLines.push('已超过 1 小时,注意控制夜间工作时长。')
2201
+ }
2202
+ }
2203
+
2204
+ box.innerHTML = `
2205
+ <div class="risk-summary">
2206
+ <div class="risk-title">【本周风险总结】</div>
2207
+ <ul>
2208
+ ${lines
2209
+ .slice(1)
2210
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2211
+ .join('')}
2212
+ </ul>
2213
+ </div>
2214
+
2215
+ <div class="risk-summary">
2216
+ <div class="risk-title">【本周最晚加班风险】</div>
2217
+ <ul>
2218
+ ${latestLines
2219
+ .slice(1)
2220
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2221
+ .join('')}
2222
+ </ul>
2223
+ </div>
2224
+ `
2225
+ }
2226
+
2227
+ function computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff) {
2228
+ const byAuthorDay = new Map()
2229
+ commits.forEach((c) => {
2230
+ const d = new Date(c.date)
2231
+ if (Number.isNaN(d.valueOf())) return
2232
+ const h = d.getHours()
2233
+ let overtime = null
2234
+ let dayKey = null
2235
+ if (h >= endHour && h < 24) {
2236
+ overtime = h - endHour
2237
+ dayKey = d.toISOString().slice(0, 10)
2238
+ } else if (h >= 0 && h < cutoff && h < startHour) {
2239
+ overtime = 24 - endHour + h
2240
+ const cur = new Date(
2241
+ Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
2242
+ )
2243
+ cur.setUTCDate(cur.getUTCDate() - 1)
2244
+ dayKey = cur.toISOString().slice(0, 10)
2245
+ }
2246
+ if (overtime == null || !dayKey) return
2247
+ const author = c.author || 'unknown'
2248
+ if (!byAuthorDay.has(author)) byAuthorDay.set(author, new Map())
2249
+ const m = byAuthorDay.get(author)
2250
+ const cur = m.get(dayKey)
2251
+ if (!cur || overtime > cur) m.set(dayKey, overtime)
2252
+ })
2253
+ return byAuthorDay
2254
+ }
2255
+
2256
+ function renderWeeklyDurationRankSummary(
2257
+ commits,
2258
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2259
+ ) {
2260
+ const box = document.getElementById('weeklyDurationRankSummary')
2261
+ if (!box) return
2262
+ const now = new Date()
2263
+ const curWeek = getIsoWeekKey(now.toISOString().slice(0, 10))
2264
+ const byAuthorDay = computeAuthorDailyMaxOvertime(
2265
+ commits,
2266
+ startHour,
2267
+ endHour,
2268
+ cutoff
2269
+ )
2270
+ const ranks = []
2271
+ byAuthorDay.forEach((dayMap, author) => {
2272
+ let total = 0
2273
+ dayMap.forEach((v, dayKey) => {
2274
+ const wk = getIsoWeekKey(dayKey)
2275
+ if (wk === curWeek) total += v
2276
+ })
2277
+ if (total > 0) ranks.push({ author, total })
2278
+ })
2279
+ ranks.sort(
2280
+ (a, b) =>
2281
+ b.total - a.total || String(a.author).localeCompare(String(b.author))
2282
+ )
2283
+
2284
+ const lines = []
2285
+ lines.push('【本周加班时长排名】')
2286
+ if (ranks.length === 0) {
2287
+ lines.push('本周暂无加班时长。')
2288
+ } else {
2289
+ ranks.forEach((r, idx) => {
2290
+ const rank = idx + 1
2291
+ const medal =
2292
+ rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
2293
+ const title = rank === 1 ? '(状元・夜魔侠)' : ''
2294
+ lines.push(
2295
+ `${rank}. ${medal}${r.author} — ${r.total.toFixed(2)} 小时${title}`
2296
+ )
2297
+ })
2298
+ }
2299
+ box.innerHTML = `
2300
+ <div class="risk-summary">
2301
+ <div class="risk-title">【本周加班时长排名】</div>
2302
+ <ul>
2303
+ ${lines
2304
+ .slice(1)
2305
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2306
+ .join('')}
2307
+ </ul>
2308
+ </div>
2309
+ `
2310
+ }
2311
+
2312
+ function renderWeeklyDurationRiskSummary(
2313
+ commits,
2314
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2315
+ ) {
2316
+ const box = document.getElementById('weeklyDurationRiskSummary')
2317
+ if (!box) return
2318
+ const now = new Date()
2319
+ const curWeek = getIsoWeekKey(now.toISOString().slice(0, 10))
2320
+ const byAuthorDay = computeAuthorDailyMaxOvertime(
2321
+ commits,
2322
+ startHour,
2323
+ endHour,
2324
+ cutoff
2325
+ )
2326
+ const sums = []
2327
+ byAuthorDay.forEach((dayMap, author) => {
2328
+ let total = 0
2329
+ dayMap.forEach((v, dayKey) => {
2330
+ const wk = getIsoWeekKey(dayKey)
2331
+ if (wk === curWeek) total += v
2332
+ })
2333
+ if (total > 0) sums.push({ author, total })
2334
+ })
2335
+ sums.sort((a, b) => b.total - a.total)
2336
+ const top = sums.slice(0, 6)
2337
+ const lines = []
2338
+ lines.push('【本周加班时长风险】')
2339
+ if (top.length === 0) {
2340
+ lines.push('本周暂无加班时长风险。')
2341
+ } else {
2342
+ top.forEach(({ author, total }) => {
2343
+ let level = '轻度'
2344
+ if (total >= 12) level = '严重'
2345
+ else if (total >= 6) level = '中度'
2346
+ lines.push(
2347
+ `${author} 本周累计加班 ${total.toFixed(2)} 小时(${level})。`
2348
+ )
2349
+ })
2350
+ }
2351
+ box.innerHTML = `
2352
+ <div class="risk-summary">
2353
+ <div class="risk-title">【本周加班时长风险】</div>
2354
+ <ul>
2355
+ ${lines
2356
+ .slice(1)
2357
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2358
+ .join('')}
2359
+ </ul>
2360
+ </div>
2361
+ `
2362
+ }
2363
+
2364
+ function renderMonthlyDurationRankSummary(
2365
+ commits,
2366
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2367
+ ) {
2368
+ const box = document.getElementById('monthlyDurationRankSummary')
2369
+ if (!box) return
2370
+ const now = new Date()
2371
+ const curMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
2372
+ const byAuthorDay = computeAuthorDailyMaxOvertime(
2373
+ commits,
2374
+ startHour,
2375
+ endHour,
2376
+ cutoff
2377
+ )
2378
+ const ranks = []
2379
+ byAuthorDay.forEach((dayMap, author) => {
2380
+ let total = 0
2381
+ dayMap.forEach((v, dayKey) => {
2382
+ const m = dayKey.slice(0, 7)
2383
+ if (m === curMonth) total += v
2384
+ })
2385
+ if (total > 0) ranks.push({ author, total })
2386
+ })
2387
+ ranks.sort(
2388
+ (a, b) =>
2389
+ b.total - a.total || String(a.author).localeCompare(String(b.author))
2390
+ )
2391
+
2392
+ const lines = []
2393
+ lines.push('【本月加班时长排名】')
2394
+ if (ranks.length === 0) {
2395
+ lines.push('本月暂无加班时长。')
2396
+ } else {
2397
+ ranks.forEach((r, idx) => {
2398
+ const rank = idx + 1
2399
+ const medal =
2400
+ rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
2401
+ const title = rank === 1 ? '(状元・夜魔侠)' : ''
2402
+ lines.push(
2403
+ `${rank}. ${medal}${r.author} — ${r.total.toFixed(2)} 小时${title}`
2404
+ )
2405
+ })
2406
+ }
2407
+ box.innerHTML = `
2408
+ <div class="risk-summary">
2409
+ <div class="risk-title">【本月加班时长排名】</div>
2410
+ <ul>
2411
+ ${lines
2412
+ .slice(1)
2413
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2414
+ .join('')}
2415
+ </ul>
2416
+ </div>
2417
+ `
2418
+ }
2419
+
2420
+ function renderMonthlyDurationRiskSummary(
2421
+ commits,
2422
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2423
+ ) {
2424
+ const box = document.getElementById('monthlyDurationRiskSummary')
2425
+ if (!box) return
2426
+ const now = new Date()
2427
+ const curMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
2428
+ const byAuthorDay = computeAuthorDailyMaxOvertime(
2429
+ commits,
2430
+ startHour,
2431
+ endHour,
2432
+ cutoff
2433
+ )
2434
+ const sums = []
2435
+ byAuthorDay.forEach((dayMap, author) => {
2436
+ let total = 0
2437
+ dayMap.forEach((v, dayKey) => {
2438
+ const m = dayKey.slice(0, 7)
2439
+ if (m === curMonth) total += v
2440
+ })
2441
+ if (total > 0) sums.push({ author, total })
2442
+ })
2443
+ sums.sort((a, b) => b.total - a.total)
2444
+ const top = sums.slice(0, 6)
2445
+ const lines = []
2446
+ lines.push('【本月加班时长风险】')
2447
+ if (top.length === 0) {
2448
+ lines.push('本月暂无加班时长风险。')
2449
+ } else {
2450
+ top.forEach(({ author, total }) => {
2451
+ let level = '轻度'
2452
+ if (total >= 20) level = '严重'
2453
+ else if (total >= 10) level = '中度'
2454
+ lines.push(
2455
+ `${author} 本月累计加班 ${total.toFixed(2)} 小时(${level})。`
2456
+ )
2457
+ })
2458
+ }
2459
+ box.innerHTML = `
2460
+ <div class="risk-summary">
2461
+ <div class="risk-title">【本月加班时长风险】</div>
2462
+ <ul>
2463
+ ${lines
2464
+ .slice(1)
2465
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2466
+ .join('')}
2467
+ </ul>
2468
+ </div>
2469
+ `
2470
+ }
2471
+
2472
+ function renderRolling30DurationRiskSummary(
2473
+ commits,
2474
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2475
+ ) {
2476
+ const box = document.getElementById('rolling30DurationRiskSummary')
2477
+ if (!box) return
2478
+ const now = new Date()
2479
+ const utcToday = new Date(
2480
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
2481
+ )
2482
+ utcToday.setUTCDate(utcToday.getUTCDate() - 29)
2483
+ const startKey = utcToday.toISOString().slice(0, 10)
2484
+
2485
+ const byAuthorDay = computeAuthorDailyMaxOvertime(
2486
+ commits,
2487
+ startHour,
2488
+ endHour,
2489
+ cutoff
2490
+ )
2491
+ const sums = []
2492
+ byAuthorDay.forEach((dayMap, author) => {
2493
+ let total = 0
2494
+ dayMap.forEach((v, dayKey) => {
2495
+ if (dayKey >= startKey) total += v
2496
+ })
2497
+ if (total > 0) sums.push({ author, total })
2498
+ })
2499
+ sums.sort((a, b) => b.total - a.total)
2500
+ const top = sums.slice(0, 6)
2501
+ const lines = []
2502
+ lines.push('【最近30天加班时长风险】')
2503
+ if (top.length === 0) {
2504
+ lines.push('最近30天暂无加班时长风险。')
2505
+ } else {
2506
+ top.forEach(({ author, total }) => {
2507
+ let level = '轻度'
2508
+ if (total >= 20) level = '严重'
2509
+ else if (total >= 10) level = '中度'
2510
+ lines.push(
2511
+ `${author} 最近30天累计加班 ${total.toFixed(2)} 小时(${level})。`
2512
+ )
2513
+ })
2514
+ }
2515
+ box.innerHTML = `
2516
+ <div class="risk-summary">
2517
+ <div class="risk-title">【最近30天加班时长风险】</div>
2518
+ <ul>
2519
+ ${lines
2520
+ .slice(1)
2521
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2522
+ .join('')}
2523
+ </ul>
2524
+ </div>
2525
+ `
2526
+ }
2527
+ function renderMonthlyRiskSummary(
2528
+ commits,
2529
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2530
+ ) {
2531
+ const box = document.getElementById('monthlyRiskSummary')
2532
+ if (!box) return
2533
+
2534
+ const now = new Date()
2535
+ const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
2536
+ const prev = new Date(now)
2537
+ prev.setMonth(prev.getMonth() - 1)
2538
+ const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
2539
+
2540
+ const monthAuthor = new Map()
2541
+ const monthMax = new Map()
2542
+
2543
+ commits.forEach((c) => {
2544
+ const d = new Date(c.date)
2545
+ if (Number.isNaN(d.valueOf())) return
2546
+ const h = d.getHours()
2547
+ const isOT =
2548
+ (h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
2549
+ if (!isOT) return
2550
+
2551
+ const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2552
+ const author = c.author || 'unknown'
2553
+
2554
+ if (!monthAuthor.has(key)) monthAuthor.set(key, new Map())
2555
+ const m = monthAuthor.get(key)
2556
+ m.set(author, (m.get(author) || 0) + 1)
2557
+
2558
+ let overtime = null
2559
+ if (h >= endHour && h < 24) overtime = h - endHour
2560
+ else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
2561
+ if (overtime == null) return
2562
+
2563
+ if (!monthMax.has(key)) monthMax.set(key, new Map())
2564
+ const mm = monthMax.get(key)
2565
+ const cur = mm.get(author)
2566
+ const dateStr = d.toISOString().slice(0, 10)
2567
+ if (!cur || overtime > cur.max)
2568
+ mm.set(author, { max: overtime, date: dateStr })
2569
+ })
2570
+
2571
+ const curMap = monthAuthor.get(curKey) || new Map()
2572
+ const prevMap = monthAuthor.get(prevKey) || new Map()
2573
+ const curTotal = Array.from(curMap.values()).reduce((a, b) => a + b, 0)
2574
+ const prevTotal = Array.from(prevMap.values()).reduce((a, b) => a + b, 0)
2575
+ const delta =
2576
+ prevTotal > 0
2577
+ ? Math.round(((curTotal - prevTotal) / prevTotal) * 100)
2578
+ : null
2579
+
2580
+ let topAuthor = null
2581
+ let top = { max: -1, date: null }
2582
+ const curMaxMap = monthMax.get(curKey) || new Map()
2583
+ curMaxMap.forEach((v, k) => {
2584
+ if (v.max > top.max) {
2585
+ top = v
2586
+ topAuthor = k
2587
+ }
2588
+ })
2589
+
2590
+ let prevMax = -1
2591
+ const prevMaxMap = monthMax.get(prevKey) || new Map()
2592
+ prevMaxMap.forEach((v) => {
2593
+ if (v.max > prevMax) prevMax = v.max
2594
+ })
2595
+
2596
+ const lines = []
2597
+ lines.push('【本月加班风险】')
2598
+
2599
+ if (curTotal === 0) {
2600
+ lines.push('本月尚无下班后提交,未发现明显风险。')
2601
+ } else {
2602
+ if (delta === null) {
2603
+ lines.push(`本月下班后提交 ${curTotal} 次。`)
2604
+ } else {
2605
+ const trend = delta >= 0 ? '上升' : '下降'
2606
+ lines.push(`本月下班后提交${trend} ${Math.abs(delta)}%(vs 上月)。`)
2607
+ }
2608
+
2609
+ if (top.max >= 0) {
2610
+ let trend2 = '暂无上月对比'
2611
+ if (prevMax >= 0) {
2612
+ if (top.max > prevMax) trend2 = '较上月更晚'
2613
+ else if (top.max < prevMax) trend2 = '较上月提前'
2614
+ else trend2 = '与上月持平'
2615
+ }
2616
+ lines.push(
2617
+ `${topAuthor} 本月最晚超出下班 ${top.max.toFixed(2)} 小时(${top.date}),${trend2}。`
2618
+ )
2619
+ if (top.max >= 2) lines.push('已超过 2 小时,存在严重加班风险。')
2620
+ else if (top.max >= 1) lines.push('已超过 1 小时,存在中度加班风险。')
2621
+ }
2622
+ }
2623
+
2624
+ // ---------- 追加:本月“最晚加班风险”独立展示(合并自 renderLatestMonthlyRiskSummary) ----------
2625
+ const latestMonthLines = []
2626
+ latestMonthLines.push('【本月最晚加班风险】')
2627
+ if (top.max < 0) {
2628
+ latestMonthLines.push('本月尚无下班后/凌晨提交,未发现明显风险。')
2629
+ } else {
2630
+ let trend3 = '暂无上月对比'
2631
+ if (prevMax >= 0) {
2632
+ if (top.max > prevMax) trend3 = '较上月更晚'
2633
+ else if (top.max < prevMax) trend3 = '较上月提前'
2634
+ else trend3 = '与上月持平'
2635
+ }
2636
+ latestMonthLines.push(
2637
+ `${topAuthor} 本月最晚超出下班 ${top.max.toFixed(2)} 小时(${top.date}),${trend3}。`
2638
+ )
2639
+ if (top.max >= 2) {
2640
+ latestMonthLines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
2641
+ } else if (top.max >= 1) {
2642
+ latestMonthLines.push('已超过 1 小时,注意控制夜间工作时长。')
2643
+ }
2644
+ }
2645
+
2646
+ box.innerHTML = `
2647
+ <div class="risk-summary">
2648
+ <div class="risk-title">【本月加班风险】</div>
2649
+ <ul>
2650
+ ${lines
2651
+ .slice(1)
2652
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2653
+ .join('')}
2654
+ </ul>
2655
+ </div>
2656
+
2657
+ <div class="risk-summary">
2658
+ <div class="risk-title">【本月最晚加班风险】</div>
2659
+ <ul>
2660
+ ${latestMonthLines
2661
+ .slice(1)
2662
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2663
+ .join('')}
2664
+ </ul>
2665
+ </div>
2666
+ `
2667
+ }
2668
+
2669
+ // ========= 开发者加班“最晚”趋势(每期取最大超时) =========
2670
+ function buildAuthorLatestDataset(commits, type, startHour, endHour, cutoff) {
2671
+ const byAuthor = new Map()
2672
+ const periods = new Set()
2673
+
2674
+ commits.forEach((c) => {
2675
+ const d = new Date(c.date)
2676
+ if (Number.isNaN(d.valueOf())) return
2677
+ const h = d.getHours()
2678
+
2679
+ let overtime = null
2680
+ if (h >= endHour && h < 24) overtime = h - endHour
2681
+ else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
2682
+ if (overtime == null) return
2683
+
2684
+ let key
2685
+ if (type === 'daily') {
2686
+ key = d.toISOString().slice(0, 10)
2687
+ } else if (type === 'weekly') {
2688
+ key = getIsoWeekKey(d.toISOString().slice(0, 10))
2689
+ } else {
2690
+ key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2691
+ }
2692
+ if (!key) return
2693
+ periods.add(key)
2694
+
2695
+ const author = c.author || 'unknown'
2696
+ if (!byAuthor.has(author)) byAuthor.set(author, {})
2697
+ const obj = byAuthor.get(author)
2698
+ obj[key] = Math.max(obj[key] || 0, overtime)
2699
+ })
2700
+
2701
+ const allPeriods = Array.from(periods).sort()
2702
+
2703
+ const authors = Array.from(byAuthor.keys()).sort()
2704
+ const series = authors.map((a) => ({
2705
+ name: a,
2706
+ type: 'line',
2707
+ smooth: true,
2708
+ data: allPeriods.map((p) => byAuthor.get(a)[p] || 0)
2709
+ }))
2710
+ return { authors, allPeriods, series }
2711
+ }
2712
+
2713
+ function drawAuthorLatestOvertimeTrends(commits, stats) {
2714
+ const el = document.getElementById('chartAuthorLatestOvertime')
2715
+ if (!el) return null
2716
+ const chart = echarts.init(el)
2717
+
2718
+ const startHour =
2719
+ typeof stats.startHour === 'number' && stats.startHour >= 0
2720
+ ? stats.startHour
2721
+ : 9
2722
+ const endHour =
2723
+ typeof stats.endHour === 'number' && stats.endHour >= 0
2724
+ ? stats.endHour
2725
+ : window.__overtimeEndHour || 18
2726
+ const cutoff = window.__overnightCutoff ?? 6
2727
+
2728
+ function render(type) {
2729
+ const ds = buildAuthorLatestDataset(
2730
+ commits,
2731
+ type,
2732
+ startHour,
2733
+ endHour,
2734
+ cutoff
2735
+ )
2736
+ ds.rangeMap = {}
2737
+
2738
+ for (const period of ds.allPeriods) {
2739
+ if (period.includes('-W')) {
2740
+ const [yy, ww] = period.split('-W')
2741
+ ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
2742
+ }
2743
+ }
2744
+ chart.setOption({
2745
+ tooltip: {
2746
+ trigger: 'axis',
2747
+ formatter(params) {
2748
+ if (!params || !params.length) return ''
2749
+
2750
+ const p = params[0]
2751
+ const label = p.axisValue
2752
+ const isWeekly = type === 'weekly'
2753
+
2754
+ let extra = ''
2755
+ if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
2756
+ const { start, end } = ds.rangeMap[label]
2757
+ extra = `<div style="margin-top:4px;color:#999;font-size:12px">
2758
+ 周区间:${start} ~ ${end}
2759
+ </div>`
2760
+ }
2761
+
2762
+ const lines = params
2763
+ .filter((i) => i.data > 0)
2764
+ .sort(
2765
+ (a, b) =>
2766
+ (b.data || 0) - (a.data || 0) ||
2767
+ String(a.seriesName).localeCompare(String(b.seriesName))
2768
+ )
2769
+ .map(
2770
+ (item) => `${item.marker}${item.seriesName}: ${item.data} 小时`
2771
+ )
2772
+ .join('<br/>')
2773
+
2774
+ return `
2775
+ <div>${label}</div>
2776
+ ${extra}
2777
+ ${lines}
2778
+ `
2779
+ }
2780
+ },
2781
+ legend: { data: ds.authors },
2782
+ xAxis: { type: 'category', data: ds.allPeriods },
2783
+ yAxis: {
2784
+ type: 'value',
2785
+ name: '超出下班(小时)',
2786
+ min: 0
2787
+ },
2788
+ series: ds.series
2789
+ })
2790
+ }
2791
+
2792
+ render('daily')
2793
+
2794
+ const tabs = document.querySelectorAll('#tabsLatestOvertime button')
2795
+ tabs.forEach((btnEl) => {
2796
+ btnEl.addEventListener('click', () => {
2797
+ tabs.forEach((b) => b.classList.remove('active'))
2798
+ btnEl.classList.add('active')
2799
+ render(btnEl.dataset.type)
2800
+ })
2801
+ })
2802
+
2803
+ // 点击事件:点击某个作者在某周期的点,打开侧栏显示该作者在该周期内的加班提交明细(用于查看具体提交与时间)
2804
+ chart.on('click', (p) => {
2805
+ try {
2806
+ if (!p || p.componentType !== 'series') return
2807
+ const label = p.axisValue || p.name
2808
+ const author = p.seriesName
2809
+ if (!label || !author) return
2810
+ const type =
2811
+ document.querySelector('#tabsLatestOvertime button.active')?.dataset
2812
+ .type || 'daily'
2813
+
2814
+ const filteredCommits = commits.filter((c) => {
2815
+ const a = c.author || 'unknown'
2816
+ if (a !== author) return false
2817
+ const d = new Date(c.date)
2818
+ if (Number.isNaN(d.valueOf())) return false
2819
+ const h = d.getHours()
2820
+ let overtime = null
2821
+ if (h >= endHour && h < 24) overtime = h - endHour
2822
+ else if (h >= 0 && h < cutoff && h < startHour)
2823
+ overtime = 24 - endHour + h
2824
+ if (overtime == null) return false
2825
+
2826
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
2827
+ if (type === 'weekly') {
2828
+ if (!label.includes('-W')) return false
2829
+ const [yy, ww] = label.split('-W')
2830
+ const range = getISOWeekRange(Number(yy), Number(ww))
2831
+ const day = d.toISOString().slice(0, 10)
2832
+ return day >= range.start && day <= range.end
2833
+ }
2834
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2835
+ return month === label
2836
+ })
2837
+
2838
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
2839
+
2840
+ if (type === 'weekly') {
2841
+ const weeklyItem = {
2842
+ outsideWorkCount: filteredCommits.length,
2843
+ outsideWorkRate: 0
2844
+ }
2845
+ showSideBarForWeek({
2846
+ period: label,
2847
+ weeklyItem,
2848
+ commits: filteredCommits,
2849
+ titleDrawer: `${author} 本周最晚加班详情`
2850
+ })
2851
+ } else {
2852
+ showDayDetailSidebar({
2853
+ date: label,
2854
+ count: filteredCommits.length,
2855
+ commits: filteredCommits,
2856
+ titleDrawer: `${author} 本日最晚加班详情`
2857
+ })
2858
+ }
2859
+ } catch (err) {
2860
+ console.warn('Latest overtime chart click handler error', err)
2861
+ }
2862
+ })
2863
+
2864
+ return chart
2865
+ }
2866
+
2867
+ // ====== 开发者 累计加班时长(按日/周/月/年累计日峰值加班时长求和) ======
2868
+ function buildAuthorTotalOvertimeDataset(
2869
+ commits,
2870
+ type,
2871
+ startHour,
2872
+ endHour,
2873
+ cutoff
2874
+ ) {
2875
+ // 基于每天每人的最大超时(computeAuthorDailyMaxOvertime),再按周期聚合求和
2876
+ const byAuthorDay = computeAuthorDailyMaxOvertime(
2877
+ commits,
2878
+ startHour,
2879
+ endHour,
2880
+ cutoff
2881
+ )
2882
+ const byAuthorPeriod = new Map()
2883
+ const periods = new Set()
2884
+
2885
+ byAuthorDay.forEach((dayMap, author) => {
2886
+ for (const [dayKey, overtime] of dayMap.entries()) {
2887
+ let period
2888
+ if (type === 'daily') period = dayKey
2889
+ else if (type === 'weekly') period = getIsoWeekKey(dayKey)
2890
+ else if (type === 'monthly')
2891
+ period = dayKey.slice(0, 7) // YYYY-MM
2892
+ else if (type === 'yearly')
2893
+ period = dayKey.slice(0, 4) // YYYY
2894
+ else period = dayKey
2895
+ if (!period) continue
2896
+ periods.add(period)
2897
+ if (!byAuthorPeriod.has(author)) byAuthorPeriod.set(author, {})
2898
+ const obj = byAuthorPeriod.get(author)
2899
+ obj[period] = (obj[period] || 0) + (overtime || 0)
2900
+ }
2901
+ })
2902
+
2903
+ const allPeriods = Array.from(periods).sort()
2904
+ const authors = Array.from(byAuthorPeriod.keys()).sort()
2905
+ const series = authors.map((a) => ({
2906
+ name: a,
2907
+ type: 'line',
2908
+ smooth: true,
2909
+ data: allPeriods.map((p) =>
2910
+ Number((byAuthorPeriod.get(a)[p] || 0).toFixed(2))
2911
+ )
2912
+ }))
2913
+
2914
+ return { authors, allPeriods, series }
2915
+ }
2916
+
2917
+ /* 午休加班总时长----------------------------------------------------------- */
2918
+
2919
+ /* -------------------- time utils -------------------- */
2920
+
2921
+ function getISOWeek(date) {
2922
+ const d = new Date(Date.UTC(
2923
+ date.getFullYear(),
2924
+ date.getMonth(),
2925
+ date.getDate()
2926
+ ))
2927
+ const dayNum = d.getUTCDay() || 7
2928
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum)
2929
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
2930
+ return Math.ceil(((d - yearStart) / 86400000 + 1) / 7)
2931
+ }
2932
+
2933
+ function getPeriodKey(d, type) {
2934
+ if (type === 'daily') return d.toISOString().slice(0, 10)
2935
+ if (type === 'weekly')
2936
+ return `${d.getFullYear()}-W${String(getISOWeek(d)).padStart(2, '0')}`
2937
+ if (type === 'monthly')
2938
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2939
+ return String(d.getFullYear())
2940
+ }
2941
+
2942
+ function getHourFloat(d) {
2943
+ return d.getHours() + d.getMinutes() / 60
2944
+ }
2945
+
2946
+ /* -------------------- overtime detect -------------------- */
2947
+
2948
+ function isLunch(d, conf) {
2949
+ const h = getHourFloat(d)
2950
+ return h >= conf.lunchStart && h < conf.lunchEnd
2951
+ }
2952
+
2953
+ function isAfterWork(d, conf) {
2954
+ const h = getHourFloat(d)
2955
+ return h >= conf.endHour
2956
+ }
2957
+
2958
+ function isNight(d, conf) {
2959
+ const h = getHourFloat(d)
2960
+ return h >= 0 && h < conf.cutoff
2961
+ }
2962
+
2963
+ /* -------------------- core dataset builder -------------------- */
2964
+
2965
+ function buildAuthorOvertimeDurationDataset(commits, periodType, otType, conf) {
2966
+ const dayMap = new Map() // day-author -> lastDate
2967
+ const authors = new Set()
2968
+
2969
+ commits.forEach((c) => {
2970
+ const d = new Date(c.date)
2971
+ if (Number.isNaN(d)) return
2972
+
2973
+ let matched = false
2974
+ if (otType === 'lunch') matched = isLunch(d, conf)
2975
+ if (otType === 'after') matched = isAfterWork(d, conf)
2976
+ if (otType === 'night') matched = isNight(d, conf)
2977
+ if (!matched) return
2978
+
2979
+ const dayKey = d.toISOString().slice(0, 10)
2980
+ const author = c.author || 'unknown'
2981
+ const key = `${dayKey}|${author}`
2982
+
2983
+ authors.add(author)
2984
+
2985
+ const prev = dayMap.get(key)
2986
+ if (!prev || d > prev) {
2987
+ dayMap.set(key, d)
2988
+ }
2989
+ })
2990
+
2991
+ /* ---- convert daily duration ---- */
2992
+
2993
+ const periodMap = new Map()
2994
+
2995
+ dayMap.forEach((lastDate, key) => {
2996
+ const [day, author] = key.split('|')
2997
+ const d = new Date(day)
2998
+ const periodKey = getPeriodKey(d, periodType)
2999
+
3000
+ let hours = 0
3001
+ const lastHour = getHourFloat(lastDate)
3002
+
3003
+ if (otType === 'lunch') {
3004
+ hours = Math.max(0, lastHour - conf.lunchStart)
3005
+ }
3006
+ if (otType === 'after') {
3007
+ hours = Math.max(0, lastHour - conf.endHour)
3008
+ }
3009
+ if (otType === 'night') {
3010
+ hours = Math.max(0, lastHour)
3011
+ }
3012
+
3013
+ if (!periodMap.has(periodKey)) {
3014
+ periodMap.set(periodKey, {})
3015
+ }
3016
+ const row = periodMap.get(periodKey)
3017
+ row[author] = (row[author] || 0) + hours
3018
+ })
3019
+
3020
+ const allPeriods = Array.from(periodMap.keys()).sort()
3021
+ const authorList = Array.from(authors).sort()
3022
+
3023
+ const series = authorList.map((a) => ({
3024
+ name: a,
3025
+ type: 'bar',
3026
+ stack: 'total',
3027
+ data: allPeriods.map((p) =>
3028
+ Number((periodMap.get(p)?.[a] || 0).toFixed(2))
3029
+ )
3030
+ }))
3031
+
3032
+ return { allPeriods, authorList, series }
3033
+ }
3034
+
3035
+ /* -------------------- main chart -------------------- */
3036
+
3037
+ function drawAuthorOvertimeAllTrends(commits, stats) {
3038
+ const chart = echarts.init(
3039
+ document.getElementById('chartAuthorTotalLunchOvertime')
3040
+ )
3041
+
3042
+ const conf = {
3043
+ lunchStart: stats.lunchStart ?? 12,
3044
+ lunchEnd: stats.lunchEnd ?? 14,
3045
+ endHour: stats.endHour ?? 18,
3046
+ cutoff: stats.cutoff ?? 6
3047
+ }
3048
+
3049
+ let periodType = 'daily'
3050
+ let otType = 'lunch'
3051
+
3052
+ function render() {
3053
+ const ds = buildAuthorOvertimeDurationDataset(
3054
+ commits,
3055
+ periodType,
3056
+ otType,
3057
+ conf
3058
+ )
3059
+
3060
+ const otName = {
3061
+ lunch: '午休加班',
3062
+ after: '下班后加班',
3063
+ night: '凌晨加班'
3064
+ }
3065
+
3066
+ chart.setOption({
3067
+ tooltip: {
3068
+ trigger: 'axis',
3069
+ formatter(params) {
3070
+ const lines = params
3071
+ .filter((p) => p.data > 0)
3072
+ .map(
3073
+ (p) => `${p.marker}${p.seriesName}: ${p.data} 小时`
3074
+ )
3075
+ .join('<br/>')
3076
+ return `<b>${params[0].axisValue}</b><br/>${lines}`
3077
+ }
3078
+ },
3079
+ legend: { data: ds.authorList },
3080
+ xAxis: { type: 'category', data: ds.allPeriods },
3081
+ yAxis: { type: 'value', name: `${otName[otType]}(小时)` },
3082
+ series: ds.series
3083
+ })
3084
+ }
3085
+
3086
+ render()
3087
+
3088
+ document
3089
+ .querySelectorAll('#tabsOvertimeType button')
3090
+ .forEach((btn) => {
3091
+ btn.onclick = () => {
3092
+ document
3093
+ .querySelectorAll('#tabsOvertimeType button')
3094
+ .forEach((b) => b.classList.remove('active'))
3095
+ btn.classList.add('active')
3096
+ otType = btn.dataset.ot
3097
+ render()
3098
+ }
3099
+ })
3100
+
3101
+
3102
+ document
3103
+ .querySelectorAll('#tabsTotalLunchOvertime button')
3104
+ .forEach((btn) => {
3105
+ btn.onclick = () => {
3106
+ document
3107
+ .querySelectorAll('#tabsTotalLunchOvertime button')
3108
+ .forEach((b) => b.classList.remove('active'))
3109
+ btn.classList.add('active')
3110
+ periodType = btn.dataset.type
3111
+ render()
3112
+ }
3113
+ })
3114
+
3115
+ return chart
3116
+ }
3117
+
3118
+
3119
+
3120
+ /* 午休加班总时长--------------------------------------------------------------- End */
3121
+
3122
+ function drawAuthorTotalOvertimeTrends(commits, stats) {
3123
+ // FIXME: remove debug log before production
3124
+ console.log('❌', 'stats', stats);
3125
+ const el = document.getElementById('chartAuthorTotalOvertime')
3126
+ if (!el) return null
3127
+ const chart = echarts.init(el)
3128
+
3129
+ const startHour =
3130
+ typeof stats.startHour === 'number' && stats.startHour >= 0
3131
+ ? stats.startHour
3132
+ : 9
3133
+ const endHour =
3134
+ typeof stats.endHour === 'number' && stats.endHour >= 0
3135
+ ? stats.endHour
3136
+ : window.__overtimeEndHour || 18
3137
+ const cutoff = window.__overnightCutoff ?? 6
3138
+
3139
+ function render(type) {
3140
+ const ds = buildAuthorTotalOvertimeDataset(
3141
+ commits,
3142
+ type,
3143
+ startHour,
3144
+ endHour,
3145
+ cutoff
3146
+ )
3147
+ ds.rangeMap = {}
3148
+ for (const period of ds.allPeriods) {
3149
+ if (period.includes('-W')) {
3150
+ const [yy, ww] = period.split('-W')
3151
+ ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
3152
+ }
3153
+ }
3154
+
3155
+ chart.setOption({
3156
+ tooltip: {
3157
+ trigger: 'axis',
3158
+ formatter(params) {
3159
+ if (!params || !params.length) return ''
3160
+ const label = params[0].axisValue
3161
+ const isWeekly = type === 'weekly'
3162
+ let extra = ''
3163
+ if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
3164
+ const { start, end } = ds.rangeMap[label]
3165
+ extra = `<div style="margin-top:4px;color:#999;font-size:12px">周区间:${start} ~ ${end}</div>`
3166
+ }
3167
+ const lines = params
3168
+ .filter((i) => i.data > 0)
3169
+ .sort(
3170
+ (a, b) =>
3171
+ (b.data || 0) - (a.data || 0) ||
3172
+ String(a.seriesName).localeCompare(String(b.seriesName))
3173
+ )
3174
+ .map(
3175
+ (item) => `${item.marker}${item.seriesName}: ${item.data} 小时`
3176
+ )
3177
+ .join('<br/>')
3178
+ return `<div>${label}</div>${extra}${lines}`
3179
+ }
3180
+ },
3181
+ legend: { data: ds.authors },
3182
+ xAxis: { type: 'category', data: ds.allPeriods },
3183
+ yAxis: { type: 'value', name: '累计加班时长 (小时)' },
3184
+ series: ds.series
3185
+ })
3186
+
3187
+ // 同步更新下面的排名列表
3188
+ try {
3189
+ renderAuthorTotalOvertimeRankFromDs(ds, 0)
3190
+ renderAuthorTotalOvertimeRank(ds, 0)
3191
+ } catch (e) {
3192
+ console.warn('更新累计加班排名失败', e)
3193
+ }
3194
+ }
3195
+
3196
+ render('daily')
3197
+
3198
+ const tabs = document.querySelectorAll('#tabsTotalOvertime button')
3199
+ tabs.forEach((btnEl) => {
3200
+ btnEl.addEventListener('click', () => {
3201
+ tabs.forEach((b) => b.classList.remove('active'))
3202
+ btnEl.classList.add('active')
3203
+ render(btnEl.dataset.type)
3204
+ })
3205
+ })
3206
+
3207
+ // 点击事件:展示该作者在该周期的加班详情(过滤出下班/凌晨提交)
3208
+ chart.on('click', (p) => {
3209
+ try {
3210
+ if (!p || p.componentType !== 'series') return
3211
+ const label = p.axisValue || p.name
3212
+ const author = p.seriesName
3213
+ if (!label || !author) return
3214
+ const type =
3215
+ document.querySelector('#tabsTotalOvertime button.active')?.dataset
3216
+ .type || 'daily'
3217
+
3218
+ const filteredCommits = commits.filter((c) => {
3219
+ const a = c.author || 'unknown'
3220
+ if (a !== author) return false
3221
+ const d = new Date(c.date)
3222
+ if (Number.isNaN(d.valueOf())) return false
3223
+ const h = d.getHours()
3224
+ const isOT =
3225
+ (h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
3226
+ if (!isOT) return false
3227
+
3228
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
3229
+ if (type === 'weekly') {
3230
+ if (!label.includes('-W')) return false
3231
+ const [yy, ww] = label.split('-W')
3232
+ const range = getISOWeekRange(Number(yy), Number(ww))
3233
+ const day = d.toISOString().slice(0, 10)
3234
+ return day >= range.start && day <= range.end
3235
+ }
3236
+ if (type === 'monthly') {
3237
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
3238
+ return month === label
3239
+ }
3240
+ // yearly
3241
+ const year = String(d.getFullYear())
3242
+ return year === label
3243
+ })
3244
+
3245
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
3246
+
3247
+ if (type === 'weekly') {
3248
+ const weeklyItem = {
3249
+ outsideWorkCount: filteredCommits.length,
3250
+ outsideWorkRate: 0
3251
+ }
3252
+ showSideBarForWeek({
3253
+ period: label,
3254
+ weeklyItem,
3255
+ commits: filteredCommits,
3256
+ titleDrawer: `${author} 累计加班 本周详情`
3257
+ })
3258
+ } else {
3259
+ showDayDetailSidebar({
3260
+ date: label,
3261
+ count: filteredCommits.length,
3262
+ commits: filteredCommits,
3263
+ titleDrawer: `${author} 累计加班 ${type} 详情`
3264
+ })
3265
+ }
3266
+ } catch (err) {
3267
+ console.warn('Total overtime chart click handler error', err)
3268
+ }
3269
+ })
3270
+
3271
+ return chart
3272
+ }
3273
+
3274
+ /**
3275
+ * 渲染作者加班时长分布饼图
3276
+ * @param {Object} ds - 数据源
3277
+ * @param {Number} topN - 饼图展示的前N名,默认10,设为0则展示全部(不推荐在饼图中设为0)
3278
+ */
3279
+ function renderAuthorTotalOvertimeRank(ds, topN = 10) {
3280
+ if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) return;
3281
+
3282
+ // 1. 数据预处理:计算每个作者的总时长
3283
+ const seriesMap = new Map(ds.series.map(s => [s.name, s.data]));
3284
+
3285
+ const totals = ds.authors.map(author => {
3286
+ const data = seriesMap.get(author);
3287
+ const total = Array.isArray(data)
3288
+ ? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
3289
+ : 0;
3290
+ return { name: author, value: Number(total.toFixed(2)) };
3291
+ });
3292
+
3293
+ // 2. 排序:从高到低
3294
+ totals.sort((a, b) => b.value - a.value);
3295
+
3296
+ // 3. 核心逻辑:处理 topN 和 “其他” 逻辑
3297
+ let chartData = [];
3298
+ if (topN > 0 && totals.length > topN) {
3299
+ // 截取前 N 名
3300
+ chartData = totals.slice(0, topN);
3301
+ // 汇总剩余的为“其他”
3302
+ const othersValue = totals.slice(topN).reduce((sum, item) => sum + item.value, 0);
3303
+ chartData.push({
3304
+ name: '其他',
3305
+ value: Number(othersValue.toFixed(2))
3306
+ });
3307
+ } else {
3308
+ // topN 为 0 时展示全部
3309
+ chartData = totals;
3310
+ }
3311
+
3312
+ // 4. 自适应颜色生成
3313
+ const generateColors = (count) => {
3314
+ const presets = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc'];
3315
+ if (count <= presets.length) return presets.slice(0, count);
3316
+
3317
+ return chartData.map((_, i) => {
3318
+ if (i < presets.length) return presets[i];
3319
+ // 超过预设后,动态生成 HSL 颜色
3320
+ return `hsl(${(i * 137.5) % 360}, 60%, 65%)`; // 使用黄金角度 137.5 确保颜色分布均匀
3321
+ });
3322
+ };
3323
+
3324
+ // 5. 调用现有绘图方法
3325
+ return drawPieWithTotal({
3326
+ el: 'authorTotalOvertimeRankSummary',
3327
+ title: '加班总时长排名分布',
3328
+ unit: '小时',
3329
+ totalLabel: '总时长',
3330
+ data: chartData,
3331
+ colors: generateColors(chartData.length)
3332
+ });
3333
+ }
3334
+
3335
+ // 渲染累计加班排名(chart 下方)
3336
+ function renderAuthorTotalOvertimeRankFromDs(ds, topN = 20) {
3337
+ const box = document.getElementById('authorTotalOvertimeRank');
3338
+ if (!box) return;
3339
+
3340
+ if (!ds || !Array.isArray(ds.authors) || !Array.isArray(ds.series)) {
3341
+ box.innerHTML = '<div style="color:#777">暂无加班时长数据</div>';
3342
+ return;
3343
+ }
3344
+
3345
+ // 1. 建立索引映射 (O(n) 性能优化)
3346
+ const seriesMap = new Map(ds.series.map(s => [s.name, s.data]));
3347
+
3348
+ const totals = ds.authors.map((author) => {
3349
+ const data = seriesMap.get(author);
3350
+ const total = Array.isArray(data)
3351
+ ? data.reduce((sum, v) => sum + (Number(v) || 0), 0)
3352
+ : 0;
3353
+ return { author, total };
3354
+ });
3355
+
3356
+ // 2. 排序
3357
+ totals.sort((x, y) => y.total - x.total || String(x.author).localeCompare(String(y.author)));
3358
+
3359
+ // 3. 处理 topN 为 0 输出全部的逻辑
3360
+ const top = (topN > 0) ? totals.slice(0, topN) : totals;
3361
+ const count = top.length;
3362
+
3363
+ // 4. 动态生成颜色函数 (自适应任意长度)
3364
+ const getColor = (index, totalCount) => {
3365
+ // 预定义的高质量配色方案(前10个使用精选色,超过后使用动态生成的颜色)
3366
+ const presetColors = [
3367
+ '#1976d2', '#00a76f', '#fb8c00', '#d32f2f', '#6a1b9a',
3368
+ '#00897b', '#ef5350', '#ffa000', '#5c6bc0', '#43a047'
3369
+ ];
3370
+
3371
+ if (index < presetColors.length && totalCount <= presetColors.length) {
3372
+ return presetColors[index];
3373
+ }
3374
+
3375
+ // 动态计算:在 360 度色相环上均匀分布
3376
+ // 增加 200 的偏移量是为了避开纯红色,让颜色看起来更柔和
3377
+ const hue = (index * (360 / totalCount) + 200) % 360;
3378
+ return `hsl(${hue}, 65%, 50%)`;
3379
+ };
3380
+
3381
+ const safeEscape = (str) => typeof escapeHtml === 'function'
3382
+ ? escapeHtml(str)
3383
+ : String(str).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"}[m]));
3384
+
3385
+ // 5. 渲染页面
3386
+ box.innerHTML = count
3387
+ ? top
3388
+ .map(
3389
+ (t, i) => `
3390
+ <div class="rank-item" style="display: flex; align-items: center; margin-bottom: 8px;">
3391
+ <span class="dot" style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; background:${getColor(i, count)}"></span>
3392
+ <span class="author" style="flex: 1;">${i + 1}. ${safeEscape(t.author)}</span>
3393
+ <span class="hours" style="font-weight: bold;">${Number(t.total).toFixed(2)} 小时</span>
3394
+ </div>
3395
+ `
3396
+ )
3397
+ .join('')
3398
+ : '<div style="color:#777">暂无加班时长数据</div>';
3399
+ }
3400
+
3401
+ // ========= 开发者 午休最晚提交(小时) =========
3402
+ function buildAuthorLunchDataset(
3403
+ commits,
3404
+ type,
3405
+ lunchStart = 12,
3406
+ lunchEnd = 14
3407
+ ) {
3408
+ const byAuthor = new Map()
3409
+ const periods = new Set()
3410
+
3411
+ commits.forEach((c) => {
3412
+ const d = new Date(c.date)
3413
+ if (Number.isNaN(d.valueOf())) return
3414
+ const h = d.getHours()
3415
+ const m = d.getMinutes()
3416
+ // 只考虑午休时间段内的提交
3417
+ if (!(h >= lunchStart && h < lunchEnd)) return
3418
+
3419
+ let key
3420
+ if (type === 'daily') key = d.toISOString().slice(0, 10)
3421
+ else if (type === 'weekly')
3422
+ key = getIsoWeekKey(d.toISOString().slice(0, 10))
3423
+ else key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
3424
+ if (!key) return
3425
+ periods.add(key)
3426
+
3427
+ const author = c.author || 'unknown'
3428
+ if (!byAuthor.has(author)) byAuthor.set(author, {})
3429
+ const obj = byAuthor.get(author)
3430
+ // 以小时小数表示提交时间(例如 12.5 表示 12:30),后者越大表示越靠近午休结束
3431
+ const hourDecimal = h + m / 60
3432
+ obj[key] = Math.max(obj[key] || 0, hourDecimal)
3433
+ })
3434
+
3435
+ const allPeriods = Array.from(periods).sort()
3436
+ const authors = Array.from(byAuthor.keys()).sort()
3437
+ const series = authors.map((a) => ({
3438
+ name: a,
3439
+ type: 'line',
3440
+ smooth: true,
3441
+ data: allPeriods.map((p) => byAuthor.get(a)[p] || 0)
3442
+ }))
3443
+ return { authors, allPeriods, series }
3444
+ }
3445
+
3446
+ function formatHourDecimal(h) {
3447
+ if (h == null || h === 0) return '-'
3448
+ const hh = Math.floor(h)
3449
+ const mm = Math.round((h - hh) * 60)
3450
+ return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`
3451
+ }
3452
+
3453
+ function drawAuthorLunchTrends(commits, stats) {
3454
+ const el = document.getElementById('chartAuthorLunch')
3455
+ if (!el) return null
3456
+ const chart = echarts.init(el)
3457
+
3458
+ const lunchStart =
3459
+ typeof stats.lunchStart === 'number'
3460
+ ? stats.lunchStart
3461
+ : (window.__lunchStart ?? 12)
3462
+ const lunchEnd =
3463
+ typeof stats.lunchEnd === 'number'
3464
+ ? stats.lunchEnd
3465
+ : (window.__lunchEnd ?? 14)
3466
+
3467
+ function render(type) {
3468
+ const ds = buildAuthorLunchDataset(commits, type, lunchStart, lunchEnd)
3469
+ ds.rangeMap = {}
3470
+ for (const period of ds.allPeriods) {
3471
+ if (period.includes('-W')) {
3472
+ const [yy, ww] = period.split('-W')
3473
+ ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
3474
+ }
3475
+ }
3476
+
3477
+ chart.setOption({
3478
+ tooltip: {
3479
+ trigger: 'axis',
3480
+ formatter(params) {
3481
+ if (!params || !params.length) return ''
3482
+ const label = params[0].axisValue
3483
+ const isWeekly = type === 'weekly'
3484
+
3485
+ let extra = ''
3486
+ if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
3487
+ const { start, end } = ds.rangeMap[label]
3488
+ extra = `<div style="margin-top:4px;color:#999;font-size:12px">周区间:${start} ~ ${end}</div>`
3489
+ }
3490
+
3491
+ const lines = params
3492
+ .filter((i) => i.data > 0)
3493
+ .sort(
3494
+ (a, b) =>
3495
+ (b.data || 0) - (a.data || 0) ||
3496
+ String(a.seriesName).localeCompare(String(b.seriesName))
3497
+ )
3498
+ .map(
3499
+ (item) =>
3500
+ `${item.marker}${item.seriesName}: ${formatHourDecimal(item.data)}`
3501
+ )
3502
+ .join('<br/>')
3503
+
3504
+ return `<div>${label}</div>${extra}${lines}`
3505
+ }
3506
+ },
3507
+ legend: { data: ds.authors },
3508
+ xAxis: { type: 'category', data: ds.allPeriods },
3509
+ yAxis: {
3510
+ type: 'value',
3511
+ name: '时间(小时)',
3512
+ min: lunchStart,
3513
+ max: lunchEnd
3514
+ },
3515
+ series: ds.series
3516
+ })
3517
+ }
3518
+
3519
+ render('daily')
3520
+
3521
+ const tabs = document.querySelectorAll('#tabsLunch button')
3522
+ tabs.forEach((btnEl) => {
3523
+ btnEl.addEventListener('click', () => {
3524
+ tabs.forEach((b) => b.classList.remove('active'))
3525
+ btnEl.classList.add('active')
3526
+ render(btnEl.dataset.type)
3527
+ })
3528
+ })
3529
+
3530
+ renderLunchWeeklyRankSummary(commits, { lunchStart, lunchEnd })
3531
+ renderLunchWeeklyRiskSummary(commits, { lunchStart, lunchEnd })
3532
+ renderLunchMonthlyRankSummary(commits, { lunchStart, lunchEnd })
3533
+ renderLunchMonthlyRiskSummary(commits, { lunchStart, lunchEnd })
3534
+
3535
+ // 点击事件:点击某个数据点(作者+周期)打开侧栏,展示该作者在该周期午休时间段内的提交明细
3536
+ chart.on('click', (p) => {
3537
+ try {
3538
+ if (!p || p.componentType !== 'series') return
3539
+ const label = p.axisValue || p.name
3540
+ const author = p.seriesName
3541
+ if (!label || !author) return
3542
+
3543
+ // 识别当前 tabs 类型(daily|weekly|monthly)
3544
+ const type =
3545
+ document.querySelector('#tabsLunch button.active')?.dataset.type ||
3546
+ 'daily'
3547
+
3548
+ // 过滤 commits:作者匹配 + 在午休时间段内 + 在所选周期内
3549
+ const filteredCommits = commits.filter((c) => {
3550
+ const a = c.author || 'unknown'
3551
+ if (a !== author) return false
3552
+ const d = new Date(c.date)
3553
+ if (Number.isNaN(d.valueOf())) return false
3554
+ const h = d.getHours()
3555
+ const m = d.getMinutes()
3556
+ if (!(h >= lunchStart && h < lunchEnd)) return false
3557
+
3558
+ if (type === 'daily') {
3559
+ return d.toISOString().slice(0, 10) === label
3560
+ }
3561
+ if (type === 'weekly') {
3562
+ // label 格式 YYYY-Www
3563
+ if (!label.includes('-W')) return false
3564
+ const [yy, ww] = label.split('-W')
3565
+ const range = getISOWeekRange(Number(yy), Number(ww))
3566
+ const day = d.toISOString().slice(0, 10)
3567
+ return day >= range.start && day <= range.end
3568
+ }
3569
+ // monthly
3570
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
3571
+ return month === label
3572
+ })
3573
+
3574
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
3575
+
3576
+ if (type === 'weekly') {
3577
+ const weeklyItem = {
3578
+ outsideWorkCount: filteredCommits.length,
3579
+ outsideWorkRate: 0
3580
+ }
3581
+ showSideBarForWeek({
3582
+ period: label,
3583
+ weeklyItem,
3584
+ commits: filteredCommits,
3585
+ titleDrawer: `${author} 午休本周提交详情`
3586
+ })
3587
+ } else {
3588
+ showDayDetailSidebar({
3589
+ date: label,
3590
+ count: filteredCommits.length,
3591
+ commits: filteredCommits,
3592
+ titleDrawer: `${author} 午休 ${type} 提交`
3593
+ })
3594
+ }
3595
+ } catch (err) {
3596
+ console.warn('Lunch chart click handler error', err)
3597
+ }
3598
+ })
3599
+
3600
+ return chart
3601
+ }
3602
+
3603
+ function renderLunchWeeklyRankSummary(
3604
+ commits,
3605
+ { lunchStart = 12, lunchEnd = 14 } = {}
3606
+ ) {
3607
+ const box = document.getElementById('lunchWeeklyRankSummary')
3608
+ if (!box) return
3609
+
3610
+ const now = new Date()
3611
+ const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
3612
+
3613
+ const weekDays = new Map() // week -> Map(author -> Set(dates))
3614
+ commits.forEach((c) => {
3615
+ const d = new Date(c.date)
3616
+ if (Number.isNaN(d.valueOf())) return
3617
+ const h = d.getHours()
3618
+ const m = d.getMinutes()
3619
+ if (!(h >= lunchStart && h < lunchEnd)) return
3620
+ const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
3621
+ if (wKey !== curKey) return
3622
+ const author = c.author || 'unknown'
3623
+ if (!weekDays.has(author)) weekDays.set(author, new Set())
3624
+ weekDays.get(author).add(d.toISOString().slice(0, 10))
3625
+ })
3626
+
3627
+ const weeklyRanks = []
3628
+ weekDays.forEach((set, author) => {
3629
+ weeklyRanks.push({ author, days: set.size })
3630
+ })
3631
+ weeklyRanks.sort(
3632
+ (a, b) =>
3633
+ b.days - a.days || String(a.author).localeCompare(String(b.author))
3634
+ )
3635
+
3636
+ const lines = []
3637
+ lines.push('【本周午休清醒者排行榜】')
3638
+ if (weeklyRanks.length === 0) {
3639
+ lines.push('本周无人午休提交,暂无清醒者排行榜。')
3640
+ } else {
3641
+ weeklyRanks.forEach((r, idx) => {
3642
+ const rank = idx + 1
3643
+ const medal =
3644
+ rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
3645
+ const title = rank === 1 ? '(状元・昼魔侠)' : ''
3646
+ lines.push(`${rank}. ${medal}${r.author} — ${r.days} 天${title}`)
3647
+ })
3648
+ }
3649
+
3650
+ box.innerHTML = `
3651
+ <div class="risk-summary">
3652
+ <div class="risk-title">【本周午休清醒者排行榜】</div>
3653
+ <ul>
3654
+ ${lines
3655
+ .slice(1)
3656
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
3657
+ .join('')}
3658
+ </ul>
3659
+ </div>
3660
+ `
3661
+ }
3662
+
3663
+ function renderLunchWeeklyRiskSummary(
3664
+ commits,
3665
+ { lunchStart = 12, lunchEnd = 14 } = {}
3666
+ ) {
3667
+ const box = document.getElementById('lunchWeeklyRiskSummary')
3668
+ if (!box) return
3669
+
3670
+ const now = new Date()
3671
+ const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
3672
+ const prev = new Date(now)
3673
+ prev.setDate(prev.getDate() - 7)
3674
+ const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
3675
+
3676
+ const weekMax = new Map() // week -> Map(author -> {val, date, time})
3677
+ commits.forEach((c) => {
3678
+ const d = new Date(c.date)
3679
+ if (Number.isNaN(d.valueOf())) return
3680
+ const h = d.getHours()
3681
+ const m = d.getMinutes()
3682
+ if (!(h >= lunchStart && h < lunchEnd)) return
3683
+ const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
3684
+ if (!wKey) return
3685
+
3686
+ if (!weekMax.has(wKey)) weekMax.set(wKey, new Map())
3687
+ const mMap = weekMax.get(wKey)
3688
+ const author = c.author || 'unknown'
3689
+ const val = h + m / 60
3690
+ const cur = mMap.get(author)
3691
+ if (!cur || val > cur.val)
3692
+ mMap.set(author, {
3693
+ val,
3694
+ date: d.toISOString().slice(0, 10),
3695
+ time: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
3696
+ })
3697
+ })
3698
+
3699
+ const curMap = weekMax.get(curKey) || new Map()
3700
+ const prevMap = weekMax.get(prevKey) || new Map()
3701
+
3702
+ let topAuthor = null
3703
+ let top = { val: -1, date: null, time: null }
3704
+ curMap.forEach((v, k) => {
3705
+ if (v.val > top.val) {
3706
+ top = v
3707
+ topAuthor = k
3708
+ }
3709
+ })
3710
+
3711
+ let prevMax = -1
3712
+ prevMap.forEach((v) => {
3713
+ if (v.val > prevMax) prevMax = v.val
3714
+ })
3715
+
3716
+ const lines = []
3717
+ lines.push('【本周午休最晚提交风险】')
3718
+
3719
+ if (top.val < 0) {
3720
+ lines.push('本周午休期间暂无提交记录。')
3721
+ } else {
3722
+ let trend = '暂无上周对比'
3723
+ if (prevMax >= 0) {
3724
+ if (top.val > prevMax) trend = '较上周更晚'
3725
+ else if (top.val < prevMax) trend = '较上周提前'
3726
+ else trend = '与上周持平'
3727
+ }
3728
+ lines.push(
3729
+ `${topAuthor} 本周午休最晚提交:${top.time}(${top.date}),${trend}。)`
3730
+ )
3731
+ if (top.val >= lunchEnd - 0.5)
3732
+ lines.push('存在午间延迟提交风险,请关注短时间内频繁占用午休。')
3733
+ }
3734
+
3735
+ box.innerHTML = `
3736
+ <div class="risk-summary">
3737
+ <div class="risk-title">【本周午休最晚提交风险】</div>
3738
+ <ul>
3739
+ ${lines
3740
+ .slice(1)
3741
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
3742
+ .join('')}
3743
+ </ul>
3744
+ </div>
3745
+ `
3746
+ }
3747
+
3748
+ function renderLunchMonthlyRankSummary(
3749
+ commits,
3750
+ { lunchStart = 12, lunchEnd = 14 } = {}
3751
+ ) {
3752
+ const box = document.getElementById('lunchMonthlyRankSummary')
3753
+ if (!box) return
3754
+
3755
+ const now = new Date()
3756
+ const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
3757
+
3758
+ const monthDays = new Map() // author -> Set(dates)
3759
+ commits.forEach((c) => {
3760
+ const d = new Date(c.date)
3761
+ if (Number.isNaN(d.valueOf())) return
3762
+ const h = d.getHours()
3763
+ const m = d.getMinutes()
3764
+ if (!(h >= lunchStart && h < lunchEnd)) return
3765
+ const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
3766
+ if (mKey !== curKey) return
3767
+ const author = c.author || 'unknown'
3768
+ if (!monthDays.has(author)) monthDays.set(author, new Set())
3769
+ monthDays.get(author).add(d.toISOString().slice(0, 10))
3770
+ })
3771
+
3772
+ const monthlyRanks = []
3773
+ monthDays.forEach((set, author) => {
3774
+ monthlyRanks.push({ author, days: set.size })
3775
+ })
3776
+ monthlyRanks.sort(
3777
+ (a, b) =>
3778
+ b.days - a.days || String(a.author).localeCompare(String(b.author))
3779
+ )
3780
+
3781
+ const lines = []
3782
+ lines.push('【本月午休清醒者排行榜】')
3783
+ if (monthlyRanks.length === 0) {
3784
+ lines.push('本月无人午休提交,暂无清醒者排行榜。')
3785
+ } else {
3786
+ monthlyRanks.forEach((r, idx) => {
3787
+ const rank = idx + 1
3788
+ const medal =
3789
+ rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
3790
+ const title = rank === 1 ? '(状元・昼魔侠)' : ''
3791
+ lines.push(`${rank}. ${medal}${r.author} — ${r.days} 天${title}`)
3792
+ })
3793
+ }
3794
+
3795
+ box.innerHTML = `
3796
+ <div class="risk-summary">
3797
+ <div class="risk-title">【本月午休清醒者排行榜】</div>
3798
+ <ul>
3799
+ ${lines
3800
+ .slice(1)
3801
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
3802
+ .join('')}
3803
+ </ul>
3804
+ </div>
3805
+ `
3806
+ }
3807
+
3808
+ function renderLunchMonthlyRiskSummary(
3809
+ commits,
3810
+ { lunchStart = 12, lunchEnd = 14 } = {}
3811
+ ) {
3812
+ const box = document.getElementById('lunchMonthlyRiskSummary')
3813
+ if (!box) return
3814
+
3815
+ const now = new Date()
3816
+ const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
3817
+ const prev = new Date(now)
3818
+ prev.setMonth(prev.getMonth() - 1)
3819
+ const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
3820
+
3821
+ const monthMax = new Map()
3822
+ commits.forEach((c) => {
3823
+ const d = new Date(c.date)
3824
+ if (Number.isNaN(d.valueOf())) return
3825
+ const h = d.getHours()
3826
+ const m = d.getMinutes()
3827
+ if (!(h >= lunchStart && h < lunchEnd)) return
3828
+ const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
3829
+ if (!monthMax.has(mKey)) monthMax.set(mKey, new Map())
3830
+ const mm = monthMax.get(mKey)
3831
+ const author = c.author || 'unknown'
3832
+ const val = h + m / 60
3833
+ const cur = mm.get(author)
3834
+ if (!cur || val > cur.val)
3835
+ mm.set(author, {
3836
+ val,
3837
+ date: d.toISOString().slice(0, 10),
3838
+ time: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
3839
+ })
3840
+ })
3841
+
3842
+ const curMap = monthMax.get(curKey) || new Map()
3843
+ const prevMap = monthMax.get(prevKey) || new Map()
3844
+
3845
+ let topAuthor = null
3846
+ let top = { val: -1, date: null }
3847
+ curMap.forEach((v, k) => {
3848
+ if (v.val > top.val) {
3849
+ top = v
3850
+ topAuthor = k
3851
+ }
3852
+ })
3853
+
3854
+ let prevMax = -1
3855
+ prevMap.forEach((v) => {
3856
+ if (v.val > prevMax) prevMax = v.val
3857
+ })
3858
+
3859
+ const lines = []
3860
+ lines.push('【本月午休最晚提交风险】')
3861
+
3862
+ if (top.val < 0) {
3863
+ lines.push('本月午休期间暂无提交记录。')
3864
+ } else {
3865
+ let trend = '暂无上月对比'
3866
+ if (prevMax >= 0) {
3867
+ if (top.val > prevMax) trend = '较上月更晚'
3868
+ else if (top.val < prevMax) trend = '较上月提前'
3869
+ else trend = '与上月持平'
3870
+ }
3871
+ lines.push(
3872
+ `${topAuthor} 本月午休最晚提交:${top.time}(${top.date}),${trend}。)`
3873
+ )
3874
+ if (top.val >= lunchEnd - 0.5)
3875
+ lines.push('存在午间延迟提交风险,请关注短时间内频繁占用午休。')
3876
+ }
3877
+
3878
+ box.innerHTML = `
3879
+ <div class="risk-summary">
3880
+ <div class="risk-title">【本月午休最晚提交风险】</div>
3881
+ <ul>
3882
+ ${lines
3883
+ .slice(1)
3884
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
3885
+ .join('')}
3886
+ </ul>
3887
+ </div>
3888
+ `
3889
+ }
3890
+
3891
+ // ====== 前端计算 overtime 数据的函数 ======
3892
+
3893
+ /**
3894
+ * 根据 commits 和配置计算小时加班统计
3895
+ */
3896
+ function computeHourlyOvertime(commits, config) {
3897
+ const startHour = config.startHour ?? 9
3898
+ const endHour = config.endHour ?? 18
3899
+ const lunchStart = config.lunchStart ?? 12
3900
+ const lunchEnd = config.lunchEnd ?? 14
3901
+
3902
+ const hourlyCommits = Array(24).fill(0)
3903
+ const hourlyOvertimeCommits = Array(24).fill(0)
3904
+ const hourlyOvertimePercent = Array(24).fill(0)
3905
+
3906
+ let latestCommitHour = -1
3907
+ let latestCommit = null
3908
+ let total = 0
3909
+ let outsideWorkCount = 0
3910
+ let latestOutsideCommit = null
3911
+ let latestOutsideCommitHour = -1
3912
+
3913
+ commits.forEach((c) => {
3914
+ const d = new Date(c.date)
3915
+ if (isNaN(d.getTime())) return
3916
+
3917
+ const h = d.getHours()
3918
+ const m = d.getMinutes()
3919
+
3920
+ hourlyCommits[h]++
3921
+ total++
3922
+
3923
+ // 更新最后一条提交
3924
+ if (!latestCommit || new Date(c.date) > new Date(latestCommit.date)) {
3925
+ latestCommit = c
3926
+ latestCommitHour = h
3927
+ }
3928
+
3929
+ // 判断是否加班:与后端保持一致——非工作时间即为加班
3930
+ // 工作时间定义:startHour <= hour < endHour,且排除午休区间
3931
+ const inWorkHours =
3932
+ h >= startHour && h < endHour && !(h >= lunchStart && h < lunchEnd)
3933
+ const isOvertime = !inWorkHours
3934
+
3935
+ if (isOvertime) {
3936
+ hourlyOvertimeCommits[h]++
3937
+ outsideWorkCount++
3938
+ // 跟踪最晚的加班提交(按严重度:小时越大越晚)
3939
+ if (!latestOutsideCommit) {
3940
+ latestOutsideCommit = c
3941
+ latestOutsideCommitHour = h
3942
+ } else {
3943
+ const curSev = h >= endHour ? h - endHour : 24 - endHour + h
3944
+ const prevSev =
3945
+ latestOutsideCommitHour >= endHour
3946
+ ? latestOutsideCommitHour - endHour
3947
+ : 24 - endHour + latestOutsideCommitHour
3948
+ if (
3949
+ curSev > prevSev ||
3950
+ (curSev === prevSev &&
3951
+ new Date(c.date) > new Date(latestOutsideCommit.date))
3952
+ ) {
3953
+ latestOutsideCommit = c
3954
+ latestOutsideCommitHour = h
3955
+ }
3956
+ }
3957
+ }
3958
+ })
3959
+
3960
+ // 计算百分比
3961
+ for (let i = 0; i < 24; i++) {
3962
+ hourlyOvertimePercent[i] = total > 0 ? hourlyOvertimeCommits[i] / total : 0
3963
+ }
3964
+
3965
+ return {
3966
+ startHour,
3967
+ endHour,
3968
+ lunchStart,
3969
+ lunchEnd,
3970
+ hourlyOvertimeCommits,
3971
+ hourlyOvertimePercent,
3972
+ latestCommitHour,
3973
+ latestCommit,
3974
+ latestOutsideCommit,
3975
+ latestOutsideCommitHour,
3976
+ total,
3977
+ outsideWorkCount,
3978
+ outsideWorkRate: total > 0 ? outsideWorkCount / total : 0
3979
+ }
3980
+ }
3981
+
3982
+ /**
3983
+ * 根据 commits 计算每周加班统计
3984
+ */
3985
+ function computeWeeklyOvertime(
3986
+ commits,
3987
+ startHour,
3988
+ endHour,
3989
+ cutoff,
3990
+ lunchStart,
3991
+ lunchEnd
3992
+ ) {
3993
+ const weekMap = new Map()
3994
+
3995
+ // 第一步:按周分组统计加班提交
3996
+ commits.forEach((c) => {
3997
+ const d = new Date(c.date)
3998
+ const h = d.getHours()
3999
+
4000
+ // 判断是否在工作时间(与后端保持一致)
4001
+ // 工作时间是 startHour <= hour < endHour,但排除午休 lunchStart <= hour < lunchEnd
4002
+ const inWorkHours =
4003
+ h >= startHour && h < endHour && !(h >= lunchStart && h < lunchEnd)
4004
+ const isOvertime = !inWorkHours
4005
+ if (!isOvertime) return
4006
+
4007
+ const weekKey = getIsoWeekKey(d.toISOString().slice(0, 10))
4008
+ if (!weekKey) return
4009
+
4010
+ if (!weekMap.has(weekKey)) {
4011
+ weekMap.set(weekKey, {
4012
+ period: weekKey,
4013
+ outsideWorkCount: 0,
4014
+ outsideWorkRate: 0,
4015
+ range: { start: '', end: '' }
4016
+ })
4017
+ }
4018
+
4019
+ weekMap.get(weekKey).outsideWorkCount++
4020
+ })
4021
+
4022
+ // 第二步:计算每周的总 commits 数以便计算比例
4023
+ const totalByWeek = new Map()
4024
+ commits.forEach((c) => {
4025
+ const d = new Date(c.date)
4026
+ const weekKey = getIsoWeekKey(d.toISOString().slice(0, 10))
4027
+ if (weekKey) {
4028
+ totalByWeek.set(weekKey, (totalByWeek.get(weekKey) || 0) + 1)
4029
+ }
4030
+ })
4031
+
4032
+ // 第三步:计算比例并填充周范围
4033
+ const weekly = Array.from(weekMap.values())
4034
+ weekly.forEach((w) => {
4035
+ const total = totalByWeek.get(w.period) || 1
4036
+ w.outsideWorkRate = w.outsideWorkCount / total
4037
+
4038
+ // 填充周的日期范围
4039
+ const [yy, ww] = w.period.split('-W')
4040
+ w.range = getISOWeekRange(Number(yy), Number(ww))
4041
+ })
4042
+
4043
+ return weekly.sort((a, b) => a.period.localeCompare(b.period))
4044
+ }
4045
+
4046
+ /**
4047
+ * 根据 commits 计算每月加班统计
4048
+ */
4049
+ function computeMonthlyOvertime(
4050
+ commits,
4051
+ startHour,
4052
+ endHour,
4053
+ cutoff,
4054
+ lunchStart,
4055
+ lunchEnd
4056
+ ) {
4057
+ const monthMap = new Map()
4058
+
4059
+ commits.forEach((c) => {
4060
+ const d = new Date(c.date)
4061
+ const h = d.getHours()
4062
+
4063
+ // 判断是否在工作时间(与后端保持一致)
4064
+ const inWorkHours =
4065
+ h >= startHour && h < endHour && !(h >= lunchStart && h < lunchEnd)
4066
+ const isOvertime = !inWorkHours
4067
+ if (!isOvertime) return
4068
+
4069
+ const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
4070
+
4071
+ if (!monthMap.has(monthKey)) {
4072
+ monthMap.set(monthKey, {
4073
+ period: monthKey,
4074
+ outsideWorkCount: 0,
4075
+ outsideWorkRate: 0
4076
+ })
4077
+ }
4078
+
4079
+ monthMap.get(monthKey).outsideWorkCount++
4080
+ })
4081
+
4082
+ // 计算比例
4083
+ const totalByMonth = new Map()
4084
+ commits.forEach((c) => {
4085
+ const d = new Date(c.date)
4086
+ const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
4087
+ totalByMonth.set(monthKey, (totalByMonth.get(monthKey) || 0) + 1)
4088
+ })
4089
+
4090
+ const monthly = Array.from(monthMap.values())
4091
+ monthly.forEach((m) => {
4092
+ const total = totalByMonth.get(m.period) || 1
4093
+ m.outsideWorkRate = m.outsideWorkCount / total
4094
+ })
4095
+
4096
+ return monthly.sort((a, b) => a.period.localeCompare(b.period))
4097
+ }
4098
+
4099
+ /**
4100
+ * 根据 commits 计算每日最晚提交时间(所有工作时间外提交的最晚时刻)
4101
+ * 与后端逻辑保持一致:只看小时部分,忽略分钟
4102
+ */
4103
+ function computeLatestByDay(
4104
+ commits,
4105
+ startHour,
4106
+ endHour,
4107
+ cutoff,
4108
+ lunchStart,
4109
+ lunchEnd
4110
+ ) {
4111
+ const cutoffHour = cutoff || 6
4112
+
4113
+ // 第一步:按日期分组所有 commits(使用本地时间日期,避免时区偏移)
4114
+ const dayGroups = {}
4115
+ commits.forEach((c) => {
4116
+ const d = new Date(c.date)
4117
+ if (isNaN(d.getTime())) return
4118
+
4119
+ const dateStr = formatDateYMD(d) // YYYY-MM-DD 本地时间
4120
+ if (!dayGroups[dateStr]) {
4121
+ dayGroups[dateStr] = []
4122
+ }
4123
+ dayGroups[dateStr].push(c)
4124
+ })
4125
+
4126
+ const dayKeys = Object.keys(dayGroups).sort()
4127
+
4128
+ // 第二步:找出虚拟日期(次日凌晨有提交但前一日无记录)
4129
+ const virtualPrevDays = new Set()
4130
+ commits.forEach((c) => {
4131
+ const d = new Date(c.date)
4132
+ if (isNaN(d.getTime())) return
4133
+
4134
+ const h = d.getHours()
4135
+ // 只看凌晨 [0, cutoff) 且 < startHour 的提交
4136
+ if (h < 0 || h >= cutoffHour || h >= startHour) return
4137
+
4138
+ const curDay = formatDateYMD(d)
4139
+ // 计算前一天(本地日期)
4140
+ const prevDate = new Date(d)
4141
+ prevDate.setDate(prevDate.getDate() - 1)
4142
+ const prevDay = formatDateYMD(prevDate)
4143
+
4144
+ // 如果前一日没有任何提交记录,则添加虚拟日期
4145
+ if (!dayGroups[prevDay]) {
4146
+ virtualPrevDays.add(prevDay)
4147
+ }
4148
+ })
4149
+
4150
+ // 第三步:合并所有日期(实际 + 虚拟)
4151
+ const allDayKeys = Array.from(
4152
+ new Set([...dayKeys, ...virtualPrevDays])
4153
+ ).sort()
4154
+
4155
+ // 第四步:计算每一天的最晚提交时间
4156
+ const latestByDay = allDayKeys.map((k) => {
4157
+ const list = dayGroups[k] || []
4158
+
4159
+ // 1) 当天下班后的提交小时:>= endHour 且 < 24
4160
+ const sameDayHours = list
4161
+ .map((c) => new Date(c.date))
4162
+ .filter((d) => !isNaN(d.getTime()))
4163
+ .map((d) => d.getHours())
4164
+ .filter((h) => h >= endHour && h < 24)
4165
+
4166
+ // 2) 次日凌晨的提交小时:在 [0, cutoffHour) 且 < startHour
4167
+ // 构造次日的本地日期键(避免使用 UTC)
4168
+ const nextDate = new Date(`${k}T00:00:00`)
4169
+ nextDate.setDate(nextDate.getDate() + 1)
4170
+ const nextKey = formatDateYMD(nextDate)
4171
+ const early = dayGroups[nextKey] || []
4172
+ const earlyHours = early
4173
+ .map((c) => new Date(c.date))
4174
+ .filter((d) => !isNaN(d.getTime()))
4175
+ .map((d) => d.getHours())
4176
+ .filter((h) => h >= 0 && h < cutoffHour && h < startHour)
4177
+
4178
+ // 3) 合并时间值:当天用原始小时,次日凌晨用 24+小时
4179
+ const overtimeValues = [...sameDayHours, ...earlyHours.map((h) => 24 + h)]
4180
+
4181
+ // 如果没有任何下班后的提交,返回 null
4182
+ if (overtimeValues.length === 0) {
4183
+ return {
4184
+ date: k,
4185
+ latestHour: null,
4186
+ latestHourNormalized: null
4187
+ }
4188
+ }
4189
+
4190
+ const latestHourNormalized = Math.max(...overtimeValues)
4191
+ const sameDayMax =
4192
+ sameDayHours.length > 0 ? Math.max(...sameDayHours) : null
4193
+
4194
+ return {
4195
+ date: k,
4196
+ latestHour: sameDayMax,
4197
+ latestHourNormalized
4198
+ }
4199
+ })
4200
+
4201
+ return latestByDay
4202
+ }
4203
+
4204
+ async function main() {
4205
+ const { commits, config, authorChanges, options } = await loadData()
4206
+ commitsAll = commits
4207
+ filtered = commitsAll.slice()
4208
+
4209
+ // 保存所有 commits 数据供小时分布图使用
4210
+ window.__allCommitsData = commits
4211
+
4212
+ // 保存采样起止(来自 /data/options.mjs 中的 period / serve 参数)供前端展示
4213
+ // 可能的形式:--since YYYY-MM-DD --until YYYY-MM-DD
4214
+ const period = options?.period || {}
4215
+ window.__samplingSince = period?.since || null
4216
+ window.__samplingUntil = period?.until || null
4217
+ // 格式化并保存采样的作者筛选信息(支持 string / { include:[], exclude:[] })
4218
+ window.__samplingAuthor = formatAuthorFilter(options?.author || null)
4219
+
4220
+ // 前端计算 overtime 数据
4221
+ const startHour = config.startHour ?? 9
4222
+ const endHour = config.endHour ?? 18
4223
+ const lunchStart = config.lunchStart ?? 12
4224
+ const lunchEnd = config.lunchEnd ?? 14
4225
+ const cutoff = config.overnightCutoff ?? 6
4226
+
4227
+ const stats = computeHourlyOvertime(commits, {
4228
+ startHour,
4229
+ endHour,
4230
+ lunchStart,
4231
+ lunchEnd
4232
+ })
4233
+
4234
+ const weekly = computeWeeklyOvertime(
4235
+ commits,
4236
+ startHour,
4237
+ endHour,
4238
+ cutoff,
4239
+ lunchStart,
4240
+ lunchEnd
4241
+ )
4242
+ const monthly = computeMonthlyOvertime(
4243
+ commits,
4244
+ startHour,
4245
+ endHour,
4246
+ cutoff,
4247
+ lunchStart,
4248
+ lunchEnd
4249
+ )
4250
+ const latestByDay = computeLatestByDay(
4251
+ commits,
4252
+ startHour,
4253
+ endHour,
4254
+ cutoff,
4255
+ lunchStart,
4256
+ lunchEnd
4257
+ )
4258
+
4259
+ window.__overtimeEndHour = endHour
4260
+ window.__overnightCutoff = cutoff
4261
+ window.__lunchStart = lunchStart
4262
+ window.__lunchEnd = lunchEnd
4263
+
4264
+ initTableControls()
4265
+ updatePager()
4266
+ renderCommitsTablePage()
4267
+
4268
+ drawHourlyOvertime(stats, (hour) => {
4269
+ // 使用举例
4270
+ const hourCommitsDetail = groupCommitsByHour(commits)
4271
+ // 将 commit 列表传给侧栏(若没有详情,则传空数组)
4272
+ showSideBarForHour({
4273
+ hour,
4274
+ commitsOrCount: hourCommitsDetail[hour] || [],
4275
+ titleDrawer: '每小时加班分布'
4276
+ })
4277
+ })
4278
+ drawOutsideVsInside(stats)
4279
+
4280
+ // 按日提交趋势:点击某天打开抽屉,显示当日所有 commits
4281
+ drawDailyTrend(commits, showDayDetailSidebar)
4282
+
4283
+ // 周趋势:保持原有点击行为(显示该周详情)
4284
+ drawWeeklyTrend(weekly, commits, showSideBarForWeek)
4285
+
4286
+ // 月趋势(加班占比):点击某个月打开抽屉,显示该月所有 commits
4287
+ drawMonthlyTrend(monthly, commits, showDayDetailSidebar)
4288
+
4289
+ // 每日最晚提交时间(小时):点击某天打开抽屉,显示当日所有 commits
4290
+ drawLatestHourDaily(latestByDay, commits, showDayDetailSidebar)
4291
+
4292
+ // 每日超过下班的小时数:点击某天打开抽屉,显示当日所有 commits
4293
+ drawDailySeverity(latestByDay, commits, showDayDetailSidebar)
4294
+
4295
+ const daily = drawDailyTrendSeverity(commits, weekly, showDayDetailSidebar)
4296
+
4297
+ console.log('最累的一天:', daily.analysis.mostTiredDay)
4298
+
4299
+ drawChangeTrends(authorChanges)
4300
+ drawAuthorOvertimeTrends(commits, stats)
4301
+ drawAuthorLatestOvertimeTrends(commits, stats)
4302
+ drawAuthorLunchTrends(commits, stats)
4303
+ // 新增:开发者累计加班时长(按日/周/月/年)
4304
+ drawAuthorTotalOvertimeTrends(commits, stats)
4305
+ drawAuthorOvertimeAllTrends(commits, stats)
4306
+ computeAndRenderLatestOvertime(latestByDay)
4307
+ renderKpi(stats)
4308
+ }
4309
+
4310
+ // 抽屉关闭交互(按钮 + 点击遮罩)
4311
+ document.getElementById('sidebarClose').onclick = () => {
4312
+ document.getElementById('dayDetailSidebar').classList.remove('show')
4313
+ const backdrop = document.getElementById('sidebarBackdrop')
4314
+ if (backdrop) backdrop.classList.remove('show')
4315
+ }
4316
+
4317
+ const sidebarBackdropEl = document.getElementById('sidebarBackdrop')
4318
+ if (sidebarBackdropEl) {
4319
+ sidebarBackdropEl.addEventListener('click', () => {
4320
+ document.getElementById('dayDetailSidebar').classList.remove('show')
4321
+ sidebarBackdropEl.classList.remove('show')
4322
+ })
4323
+ }
4324
+ main()