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,3139 @@
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
+ function getISOWeekRange(isoYear, isoWeek) {
65
+ // 找到 ISO 年的第一个周一
66
+ // ISO 年的第 1 周包含 1 月 4 日
67
+ const simple = new Date(isoYear, 0, 4)
68
+ const dayOfWeek = simple.getDay() || 7 // Sunday=7
69
+ const firstMonday = new Date(simple)
70
+ firstMonday.setDate(simple.getDate() - dayOfWeek + 1)
71
+
72
+ // 计算目标周的周一
73
+ const monday = new Date(firstMonday)
74
+ monday.setDate(firstMonday.getDate() + (isoWeek - 1) * 7)
75
+
76
+ const sunday = new Date(monday)
77
+ sunday.setDate(monday.getDate() + 6)
78
+
79
+ return {
80
+ start: formatDateYMD(monday),
81
+ end: formatDateYMD(sunday)
82
+ }
83
+ }
84
+
85
+ async function loadData() {
86
+ // 定义加载函数,包装 import 以便添加错误处理
87
+ const safeImport = async (path, defaultValue) => {
88
+ try {
89
+ const module = await import(path)
90
+ return module.default || defaultValue
91
+ } catch (e) {
92
+ console.warn(`文件加载失败: ${path}`, e)
93
+ return defaultValue
94
+ }
95
+ }
96
+
97
+ // 并行加载所有静态模块
98
+ const [commits, stats, weekly, monthly, latestByDay, config, authorChanges] =
99
+ await Promise.all([
100
+ safeImport('/data/commits.mjs', []),
101
+ safeImport('/data/overtime.mjs', {}),
102
+ safeImport('/data/overtime.week.mjs', []),
103
+ safeImport('/data/overtime.month.mjs', []),
104
+ safeImport('/data/overtime.latest.commit.day.mjs', []),
105
+ safeImport('/data/config.mjs', {}),
106
+ safeImport('/data/author.changes.mjs', {})
107
+ ])
108
+
109
+ return { commits, stats, weekly, monthly, latestByDay, config, authorChanges }
110
+ }
111
+
112
+ let commitsAll = []
113
+ let filtered = []
114
+ let page = 1
115
+ let pageSize = 10
116
+
117
+ function renderCommitsTablePage() {
118
+ const tbody = document.querySelector('#commitsTable tbody')
119
+ tbody.innerHTML = ''
120
+ const start = (page - 1) * pageSize
121
+ const end = start + pageSize
122
+ filtered.slice(start, end).forEach((c) => {
123
+ const tr = document.createElement('tr')
124
+ tr.innerHTML = `<td>${c.hash.slice(0, 8)}</td><td>${c.author}</td><td>${c.email}</td><td>${formatDate(c.date)}</td><td>${c.message}</td><td>${c.changed}</td>`
125
+ tbody.appendChild(tr)
126
+ })
127
+ document.getElementById('commitsTotal').textContent =
128
+ `共${filtered.length}条记录`
129
+ }
130
+
131
+ function updatePager() {
132
+ const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
133
+ if (page > totalPages) page = totalPages
134
+ const pageInfo = document.getElementById('pageInfo')
135
+ pageInfo.textContent = `${page} / ${totalPages}`
136
+ document.getElementById('prevPage').disabled = page <= 1
137
+ document.getElementById('nextPage').disabled = page >= totalPages
138
+ }
139
+
140
+ function applySearch() {
141
+ const q = document.getElementById('searchInput').value.trim().toLowerCase()
142
+
143
+ // ① 先做日期过滤
144
+ const base = filterByDate(commitsAll)
145
+
146
+ if (!q) {
147
+ filtered = base.slice()
148
+ } else {
149
+ filtered = base.filter((c) => {
150
+ const h = c.hash.toLowerCase()
151
+ const a = String(c.author || '').toLowerCase()
152
+ const e = String(c.email || '').toLowerCase()
153
+ const m = String(c.message || '').toLowerCase()
154
+ const d = formatDate(c.date).toLowerCase()
155
+ return (
156
+ h.includes(q) ||
157
+ a.includes(q) ||
158
+ e.includes(q) ||
159
+ m.includes(q) ||
160
+ d.includes(q)
161
+ )
162
+ })
163
+ }
164
+ page = 1
165
+ updatePager()
166
+ renderCommitsTablePage()
167
+ }
168
+
169
+ function initTableControls() {
170
+ document.getElementById('searchInput').addEventListener('input', applySearch)
171
+ document.getElementById('startDate')?.addEventListener('change', applySearch)
172
+ document.getElementById('endDate')?.addEventListener('change', applySearch)
173
+ document.getElementById('clearDate')?.addEventListener('click', () => {
174
+ document.getElementById('startDate').value = ''
175
+ document.getElementById('endDate').value = ''
176
+ applySearch()
177
+ })
178
+ document.getElementById('pageSize').addEventListener('change', (e) => {
179
+ pageSize = parseInt(e.target.value, 10) || 10
180
+ page = 1
181
+ updatePager()
182
+ renderCommitsTablePage()
183
+ })
184
+ document.getElementById('prevPage').addEventListener('click', () => {
185
+ if (page > 1) {
186
+ page -= 1
187
+ updatePager()
188
+ renderCommitsTablePage()
189
+ }
190
+ })
191
+ document.getElementById('nextPage').addEventListener('click', () => {
192
+ const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
193
+ if (page < totalPages) {
194
+ page += 1
195
+ updatePager()
196
+ renderCommitsTablePage()
197
+ }
198
+ })
199
+ }
200
+
201
+ function drawHourlyOvertime(stats, onHourClick) {
202
+ const el = document.getElementById('hourlyOvertimeChart')
203
+ const isEmpty = hideElementByObj({ el, objectName: stats })
204
+ if (isEmpty) {
205
+ return false
206
+ }
207
+ const chart = echarts.init(el)
208
+
209
+ const commits = stats.hourlyOvertimeCommits || []
210
+ const percent = stats.hourlyOvertimePercent || []
211
+ const labels = Array.from({ length: 24 }, (_, i) =>
212
+ String(i).padStart(2, '0')
213
+ )
214
+
215
+ // 颜色逻辑(与 daily severity 风格一致)
216
+ function getColor(h) {
217
+ if (h >= 21) return '#d32f2f' // 深夜加班 红
218
+ if (h >= 19) return '#fb8c00' // 夜间加班 橙
219
+ if (h >= stats.lunchStart && h < stats.lunchEnd) return '#888888' // 午休灰
220
+ if (h >= stats.startHour && h < stats.endHour) return '#1976d2' // 工作时段 蓝
221
+ return '#b71c1c' // 凌晨 红
222
+ }
223
+
224
+ const data = commits.map((v, h) => ({
225
+ value: v,
226
+ itemStyle: { color: getColor(h) }
227
+ }))
228
+
229
+ chart.setOption({
230
+ tooltip: {
231
+ trigger: 'axis',
232
+ formatter(params) {
233
+ const p = params[0]
234
+ const h = parseInt(p.axisValue, 10)
235
+ const count = p.value
236
+ const rate = (percent[h] * 100).toFixed(1)
237
+ return `
238
+ 🕒 <b>${h}:00</b><br/>
239
+ 提交次数:<b>${count}</b><br/>
240
+ 占全天比例:<b>${rate}%</b>
241
+ `
242
+ }
243
+ },
244
+
245
+ xAxis: {
246
+ type: 'category',
247
+ data: labels,
248
+ axisLabel: { color: '#555' }
249
+ },
250
+
251
+ yAxis: {
252
+ type: 'value',
253
+ min: 0,
254
+ axisLabel: { color: '#555' }
255
+ },
256
+
257
+ grid: { left: 40, right: 30, top: 20, bottom: 40 },
258
+
259
+ series: [
260
+ {
261
+ type: 'bar',
262
+ name: 'Overtime commits',
263
+ data,
264
+ barWidth: 18,
265
+
266
+ markPoint: {
267
+ symbol: 'pin',
268
+ symbolSize: 45,
269
+ itemStyle: { color: '#d32f2f' },
270
+ data: [
271
+ {
272
+ name: '最晚提交',
273
+ coord: [
274
+ String(stats.latestCommitHour).padStart(2, '0'),
275
+ commits[stats.latestCommitHour]
276
+ ]
277
+ }
278
+ ]
279
+ },
280
+
281
+ markLine: {
282
+ symbol: 'none',
283
+ animation: true,
284
+ label: { color: '#888', formatter: '{b}' },
285
+ lineStyle: { type: 'dashed', color: '#aaa' },
286
+ data: [
287
+ {
288
+ name: '上班开始',
289
+ nameValue: String(stats.startHour).padStart(2, '0'),
290
+ xAxis: String(stats.startHour).padStart(2, '0')
291
+ },
292
+ {
293
+ name: '下班时间',
294
+ nameValue: String(stats.endHour).padStart(2, '0'),
295
+ xAxis: String(stats.endHour).padStart(2, '0')
296
+ },
297
+ {
298
+ name: '午休开始',
299
+ nameValue: String(stats.lunchStart).padStart(2, '0'),
300
+ xAxis: String(stats.lunchStart).padStart(2, '0')
301
+ },
302
+ {
303
+ name: '午休结束',
304
+ nameValue: String(stats.lunchEnd).padStart(2, '0'),
305
+ xAxis: String(stats.lunchEnd).padStart(2, '0')
306
+ }
307
+ ]
308
+ }
309
+ }
310
+ ]
311
+ })
312
+
313
+ // 点击事件(点击某小时 → 打开侧栏)
314
+ if (typeof onHourClick === 'function') {
315
+ chart.on('click', (p) => {
316
+ let hour = Number(p.name)
317
+ if (p.componentType === 'markLine') {
318
+ hour = Number(p.data.xAxis)
319
+ }
320
+ document.getElementById('dayDetailSidebar').classList.remove('show')
321
+ if (Number.isNaN(hour)) return
322
+ onHourClick(hour, commits[hour])
323
+ })
324
+ }
325
+
326
+ return chart
327
+ }
328
+
329
+ // showSideBarForHour 实现
330
+ function showSideBarForHour({ hour, commitsOrCount, titleDrawer }) {
331
+ // 支持传入 number(仅次数)或 array(详细 commit 列表)
332
+ // 统一复用通用详情侧栏 DOM
333
+ const sidebar = document.getElementById('dayDetailSidebar')
334
+ const backdrop = document.getElementById('sidebarBackdrop')
335
+ const titleEl = document.getElementById('sidebarTitle')
336
+ const contentEl = document.getElementById('sidebarContent')
337
+ const drawerTitleEl = document.getElementById('sidebarDrawerTitle')
338
+
339
+ // 兼容未传入侧栏 DOM 的情况(优雅降级)
340
+ if (!sidebar || !titleEl || !contentEl) {
341
+ console.warn(
342
+ 'hourDetailSidebar DOM not found. Please add the HTML snippet.'
343
+ )
344
+ return
345
+ }
346
+
347
+ drawerTitleEl.innerHTML = titleDrawer || '🕒 小时详情'
348
+ titleEl.innerHTML = `🕒 ${String(hour).padStart(2, '0')}:00 - ${String(hour).padStart(2, '0')}:59`
349
+
350
+ // 如果只是 number,显示计数
351
+ if (typeof commitsOrCount === 'number') {
352
+ contentEl.innerHTML = `<div style="font-size:14px;">提交次数:<b>${commitsOrCount}</b></div>`
353
+ } else if (Array.isArray(commitsOrCount) && commitsOrCount.length === 0) {
354
+ contentEl.innerHTML = `<div style="font-size:14px;">当小时无提交记录</div>`
355
+ } else if (Array.isArray(commitsOrCount)) {
356
+ // commits 列表:展示作者/时间/消息(最多前 50 条,避免性能问题)
357
+ const commits = commitsOrCount.slice(0, 50)
358
+ contentEl.innerHTML = `<div class="sidebar-list">${commits
359
+ .map((c) => {
360
+ const author = c.author ?? c.name ?? 'unknown'
361
+ const time = c.date ?? c.time ?? ''
362
+ const msg = (c.message ?? c.msg ?? c.body ?? '').replace(/\n/g, ' ')
363
+ return `
364
+ <div class="sidebar-item">
365
+ <div class="sidebar-item-header">
366
+ <span class="author">👤 ${escapeHtml(author)}</span>
367
+ <span class="time">🕒 ${escapeHtml(time)}</span>
368
+ </div>
369
+ <div class="sidebar-item-message">${escapeHtml(msg)}</div>
370
+ </div>
371
+ `
372
+ })
373
+ .join('')}</div>`
374
+
375
+ if (commitsOrCount.length > 50) {
376
+ const more = commitsOrCount.length - 50
377
+ contentEl.innerHTML += `<div style="color:#888; padding:8px 0">另外 ${more} 条已省略</div>`
378
+ }
379
+ } else {
380
+ contentEl.innerHTML = `<div style="font-size:14px;">无可展示数据</div>`
381
+ }
382
+
383
+ // 打开侧栏 + 遮罩
384
+ sidebar.classList.add('show')
385
+ if (backdrop) backdrop.classList.add('show')
386
+ }
387
+
388
+ // 简单的 HTML 转义,防止 XSS 与布局断裂
389
+ function escapeHtml(str = '') {
390
+ return String(str)
391
+ .replaceAll('&', '&amp;')
392
+ .replaceAll('<', '&lt;')
393
+ .replaceAll('>', '&gt;')
394
+ .replaceAll('"', '&quot;')
395
+ .replaceAll("'", '&#39;')
396
+ }
397
+
398
+ function drawOutsideVsInside(stats) {
399
+ const el = document.getElementById('outsideVsInsideChart')
400
+ // eslint-disable-next-line no-undef
401
+ const chart = echarts.init(el)
402
+ const outside = stats.outsideWorkCount || 0
403
+ const total = stats.total || 0
404
+ const inside = Math.max(0, total - outside)
405
+ chart.setOption({
406
+ tooltip: {},
407
+ series: [
408
+ {
409
+ type: 'pie',
410
+ radius: '55%',
411
+ data: [
412
+ { value: inside, name: '工作时间内' },
413
+ { value: outside, name: '下班时间' }
414
+ ]
415
+ }
416
+ ]
417
+ })
418
+ return chart
419
+ }
420
+
421
+ function drawDailyTrend(commits, onDayClick) {
422
+ if (!Array.isArray(commits) || commits.length === 0) return null
423
+
424
+ // 聚合每日提交数量
425
+ const map = new Map()
426
+ commits.forEach((c) => {
427
+ const d = new Date(c.date).toISOString().slice(0, 10)
428
+ map.set(d, (map.get(d) || 0) + 1)
429
+ })
430
+
431
+ const labels = Array.from(map.keys()).sort()
432
+ const data = labels.map((l) => map.get(l))
433
+
434
+ const el = document.getElementById('dailyTrendChart')
435
+ const titleDrawer = el.getAttribute('data-title') || ''
436
+
437
+ // eslint-disable-next-line no-undef
438
+ const chart = echarts.init(el)
439
+
440
+ chart.setOption({
441
+ tooltip: {
442
+ trigger: 'axis',
443
+ formatter: (params) => {
444
+ const p = params?.[0]
445
+ if (!p) return ''
446
+
447
+ const date = p.axisValue
448
+ const count = p.data
449
+
450
+ // 分级说明
451
+ let level = '🟢 正常(≤5 次)'
452
+ if (count > 5 && count < 10) level = '🟠 较高频(6–10 次)'
453
+ if (count >= 10) level = '🔴 高频(≥10 次)'
454
+
455
+ return `
456
+ <div style="font-size:13px; line-height:1.5;">
457
+ <b>${date}</b><br/>
458
+ 提交次数:<b>${count}</b><br/>
459
+ 等级:${level}
460
+ </div>
461
+ `
462
+ }
463
+ },
464
+
465
+ xAxis: { type: 'category', data: labels },
466
+
467
+ yAxis: { type: 'value', min: 0 },
468
+
469
+ series: [
470
+ {
471
+ type: 'line',
472
+ name: '每日提交',
473
+ data,
474
+
475
+ smooth: true,
476
+
477
+ // ⭐ area 渐变背景
478
+ areaStyle: {
479
+ opacity: 0.2
480
+ },
481
+
482
+ // ⭐ 背景区间(低 / 中 / 高频)
483
+ markArea: {
484
+ data: [
485
+ [
486
+ { yAxis: 0 },
487
+ { yAxis: 5, itemStyle: { color: 'rgba(76, 175, 80, 0.12)' } } // 绿
488
+ ],
489
+ [
490
+ { yAxis: 5 },
491
+ { yAxis: 10, itemStyle: { color: 'rgba(251, 140, 0, 0.12)' } } // 橙
492
+ ],
493
+ [
494
+ { yAxis: 10 },
495
+ { yAxis: 50, itemStyle: { color: 'rgba(211, 47, 47, 0.12)' } } // 红
496
+ ]
497
+ ]
498
+ },
499
+
500
+ // ⭐ 阈值线
501
+ markLine: {
502
+ symbol: ['none', 'arrow'],
503
+ data: [
504
+ {
505
+ yAxis: 5,
506
+ lineStyle: { color: '#fb8c00', width: 2, type: 'dashed' },
507
+ label: { formatter: '5 次', color: '#fb8c00' }
508
+ },
509
+ {
510
+ yAxis: 10,
511
+ lineStyle: { color: '#d32f2f', width: 2, type: 'dashed' },
512
+ label: { formatter: '10 次', color: '#d32f2f' }
513
+ }
514
+ ]
515
+ }
516
+ }
517
+ ]
518
+ })
519
+
520
+ // 点击某一天,打开抽屉显示当日 commits
521
+ if (typeof onDayClick === 'function') {
522
+ chart.on('click', (params) => {
523
+ const idx = params.dataIndex
524
+ const date = labels[idx]
525
+ const count = data[idx]
526
+ const dayCommits = commits.filter(
527
+ (c) => new Date(c.date).toISOString().slice(0, 10) === date
528
+ )
529
+ // onDayClick(date, count, dayCommits)
530
+ onDayClick({
531
+ date,
532
+ count,
533
+ commits: dayCommits,
534
+ titleDrawer
535
+ })
536
+ })
537
+ }
538
+
539
+ return chart
540
+ }
541
+
542
+ function showSideBarForWeek({ period, weeklyItem, commits = [], titleDrawer }) {
543
+ // 统一复用通用详情侧栏 DOM
544
+ const sidebar = document.getElementById('dayDetailSidebar')
545
+ const backdrop = document.getElementById('sidebarBackdrop')
546
+ const titleEl = document.getElementById('sidebarTitle')
547
+ const contentEl = document.getElementById('sidebarContent')
548
+ const drawerTitleEl = document.getElementById('sidebarDrawerTitle')
549
+
550
+ titleEl.innerHTML = `📅 周期:<b>${period}</b>`
551
+ drawerTitleEl.innerHTML = titleDrawer || ''
552
+
553
+ let html = `
554
+ <div style="padding:6px 0;">
555
+ 加班次数:<b>${weeklyItem.outsideWorkCount}</b><br/>
556
+ 占比:<b>${(weeklyItem.outsideWorkRate * 100).toFixed(1)}%</b>
557
+ </div>
558
+ <hr/>
559
+ `
560
+
561
+ if (!commits.length) {
562
+ html += `<div style="padding:10px;color:#777;">该周无提交记录</div>`
563
+ } else {
564
+ html += `<div class="sidebar-list">${commits
565
+ .map((c) => {
566
+ const author = escapeHtml(c.author || 'unknown')
567
+ const time = escapeHtml(c.date || '')
568
+ const msg = escapeHtml((c.message || '').replace(/\n/g, ' '))
569
+ return `
570
+ <div class="sidebar-item">
571
+ <div class="sidebar-item-header">
572
+ <span class="author">👤 ${author}</span>
573
+ <span class="time">🕒 ${time}</span>
574
+ </div>
575
+ <div class="sidebar-item-message">${msg}</div>
576
+ </div>
577
+ `
578
+ })
579
+ .join('')}</div>`
580
+ }
581
+
582
+ contentEl.innerHTML = html
583
+ sidebar.classList.add('show')
584
+ if (backdrop) backdrop.classList.add('show')
585
+ }
586
+
587
+ function drawWeeklyTrend(weekly, commits, onWeekClick) {
588
+ const el = document.getElementById('weeklyTrendChart')
589
+ const isEmpty = hideElementByObj({ el, objectName: weekly })
590
+ if (isEmpty) {
591
+ return null
592
+ }
593
+ if (!Array.isArray(weekly) || weekly.length === 0) {
594
+ return null
595
+ }
596
+
597
+ const labels = weekly.map((w) => w.period)
598
+ const dataRate = weekly.map((w) => +(w.outsideWorkRate * 100).toFixed(1)) // %
599
+ const dataCount = weekly.map((w) => w.outsideWorkCount)
600
+
601
+
602
+ const titleDrawer = el.getAttribute('data-title') || ''
603
+
604
+ const chart = echarts.init(el)
605
+
606
+ chart.setOption({
607
+ tooltip: {
608
+ trigger: 'axis',
609
+ formatter: (params) => {
610
+ const pp = params[0]
611
+ const weekItem = weekly[pp.dataIndex]
612
+ const { start, end } = weekItem.range
613
+
614
+ const rate = params.find((p) => p.seriesName.includes('%'))?.data
615
+ const count = params.find((p) => p.seriesName.includes('次数'))?.data
616
+
617
+ // 加班等级
618
+ let level = '🟢 健康(<10%)'
619
+ if (rate >= 10 && rate < 20) level = '🟠 中度(10–20%)'
620
+ if (rate >= 20) level = '🔴 严重(≥20%)'
621
+
622
+ return `
623
+ <div style="font-size:13px; line-height:1.5;">
624
+ <b>${params[0].axisValue}</b><br/>
625
+ 📅 周区间:<b>${start} ~ ${end}</b><br/>
626
+ 加班占比:<b>${rate}%</b><br/>
627
+ 加班次数:${count} 次<br/>
628
+ 等级:${level}
629
+ </div>
630
+ `
631
+ }
632
+ },
633
+
634
+ legend: { top: 10 },
635
+
636
+ xAxis: { type: 'category', data: labels },
637
+ yAxis: [
638
+ { type: 'value', min: 0, max: 100, name: '占比(%)' },
639
+ { type: 'value', name: '次数', min: 0 }
640
+ ],
641
+
642
+ series: [
643
+ {
644
+ type: 'line',
645
+ name: '加班占比(%)',
646
+ data: dataRate,
647
+ markArea: {
648
+ data: [
649
+ [
650
+ { yAxis: 0 },
651
+ { yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } }
652
+ ],
653
+ [
654
+ { yAxis: 10 },
655
+ { yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } }
656
+ ],
657
+ [
658
+ { yAxis: 20 },
659
+ { yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } }
660
+ ]
661
+ ]
662
+ },
663
+ markLine: {
664
+ symbol: ['none', 'arrow'],
665
+ data: [
666
+ {
667
+ yAxis: 10,
668
+ lineStyle: { color: '#fb8c00', width: 2, type: 'dashed' },
669
+ label: { formatter: '10%', color: '#fb8c00' }
670
+ },
671
+ {
672
+ yAxis: 20,
673
+ lineStyle: { color: '#d32f2f', width: 2, type: 'dashed' },
674
+ label: { formatter: '20%', color: '#d32f2f' }
675
+ }
676
+ ]
677
+ }
678
+ },
679
+
680
+ {
681
+ type: 'line',
682
+ name: '加班次数',
683
+ data: dataCount,
684
+ yAxisIndex: 1,
685
+ smooth: true
686
+ }
687
+ ]
688
+ })
689
+
690
+ // ⭐ 点击事件:从 commits 过滤该周提交
691
+ chart.on('click', (p) => {
692
+ const idx = p.dataIndex
693
+ const w = weekly[idx]
694
+
695
+ const start = new Date(w.range.start)
696
+ const end = new Date(w.range.end)
697
+ end.setHours(23, 59, 59, 999) // 包含当天
698
+
699
+ const weeklyCommits = commits.filter((c) => {
700
+ const d = new Date(c.date)
701
+ return d >= start && d <= end
702
+ })
703
+
704
+ // 回调交给外面决定如何打开侧栏
705
+ if (typeof onWeekClick === 'function') {
706
+ // onWeekClick(w.period, w, weeklyCommits)
707
+ onWeekClick({
708
+ period: w.period,
709
+ weeklyItem: w,
710
+ commits: weeklyCommits,
711
+ titleDrawer
712
+ })
713
+ }
714
+ })
715
+
716
+ return chart
717
+ }
718
+
719
+ function drawMonthlyTrend(monthly, commits, onMonthClick) {
720
+ const el = document.getElementById('monthlyTrendChart')
721
+ const isEmpty = hideElementByObj({ el, objectName: monthly })
722
+ if (isEmpty) {
723
+ return null
724
+ }
725
+ if (!Array.isArray(monthly) || monthly.length === 0) return null
726
+
727
+ const labels = monthly.map((m) => m.period)
728
+ const dataRate = monthly.map((m) => +(m.outsideWorkRate * 100).toFixed(1)) // 0–100%
729
+
730
+ const titleDrawer = el.getAttribute('data-title') || ''
731
+ // eslint-disable-next-line no-undef
732
+ const chart = echarts.init(el)
733
+
734
+ chart.setOption({
735
+ tooltip: {
736
+ trigger: 'axis',
737
+ formatter: (params) => {
738
+ const p = params[0]
739
+ if (!p) return ''
740
+
741
+ const rate = p.data
742
+ let level = '🟢 健康(<10%)'
743
+ if (rate >= 10 && rate < 20) level = '🟠 中度(10–20%)'
744
+ if (rate >= 20) level = '🔴 严重(≥20%)'
745
+
746
+ return `
747
+ <div style="font-size:13px; line-height:1.5">
748
+ <b>${p.axisValue}</b><br/>
749
+ 加班占比:<b>${rate}%</b><br/>
750
+ 加班等级:${level}
751
+ </div>
752
+ `
753
+ }
754
+ },
755
+
756
+ xAxis: { type: 'category', data: labels },
757
+ yAxis: { type: 'value', min: 0, max: 100 },
758
+
759
+ series: [
760
+ {
761
+ type: 'line',
762
+ name: '加班占比(%)',
763
+ data: dataRate,
764
+
765
+ // ⭐ 区间背景(可配置)
766
+ markArea: {
767
+ data: [
768
+ // <10% 绿色轻度
769
+ [
770
+ { yAxis: 0 },
771
+ { yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } }
772
+ ],
773
+ // 10–20% 橙色中度
774
+ [
775
+ { yAxis: 10 },
776
+ { yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } }
777
+ ],
778
+ // ≥20% 红色严重
779
+ [
780
+ { yAxis: 20 },
781
+ { yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } }
782
+ ]
783
+ ]
784
+ },
785
+
786
+ // ⭐ 阈值线(同每日图风格)
787
+ markLine: {
788
+ symbol: ['none', 'arrow'],
789
+ data: [
790
+ {
791
+ yAxis: 10,
792
+ lineStyle: {
793
+ color: '#fb8c00',
794
+ width: 2,
795
+ type: 'dashed'
796
+ },
797
+ label: {
798
+ formatter: '10%',
799
+ color: '#fb8c00'
800
+ }
801
+ },
802
+ {
803
+ yAxis: 20,
804
+ lineStyle: {
805
+ color: '#d32f2f',
806
+ width: 2,
807
+ type: 'dashed'
808
+ },
809
+ label: {
810
+ formatter: '20%',
811
+ color: '#d32f2f'
812
+ }
813
+ }
814
+ ]
815
+ }
816
+ }
817
+ ]
818
+ })
819
+
820
+ // 点击某个月份,打开抽屉显示该月的所有 commits
821
+ if (typeof onMonthClick === 'function' && Array.isArray(commits)) {
822
+ chart.on('click', (params) => {
823
+ const idx = params.dataIndex
824
+ const ym = labels[idx] // 'YYYY-MM'
825
+ const monthCommits = commits.filter((c) => {
826
+ const d = new Date(c.date)
827
+ const m = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(
828
+ 2,
829
+ '0'
830
+ )}`
831
+ return m === ym
832
+ })
833
+ // onMonthClick(ym, monthCommits.length, monthCommits)
834
+ onMonthClick({
835
+ date: ym,
836
+ count: monthCommits.length,
837
+ commits: monthCommits,
838
+ titleDrawer
839
+ })
840
+ })
841
+ }
842
+
843
+ return chart
844
+ }
845
+
846
+ function drawLatestHourDaily(latestByDay, commits, onDayClick) {
847
+ const el = document.getElementById('latestHourDailyChart')
848
+ const isEmpty = hideElementByObj({ el, objectName: latestByDay })
849
+ if (isEmpty) {
850
+ return null
851
+ }
852
+ if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
853
+
854
+ const labels = latestByDay.map((d) => d.date)
855
+
856
+ const raw = latestByDay.map((d) =>
857
+ typeof d.latestHourNormalized === 'number'
858
+ ? d.latestHourNormalized
859
+ : (d.latestHour ?? null)
860
+ )
861
+
862
+ // 数据点颜色
863
+ const data = raw.map((v) => ({
864
+ value: v,
865
+ itemStyle: {
866
+ color:
867
+ // eslint-disable-next-line no-nested-ternary
868
+ v >= 20
869
+ ? '#d32f2f' // 红
870
+ : v >= 19
871
+ ? '#fb8c00' // 橙
872
+ : '#1976d2' // 蓝
873
+ }
874
+ }))
875
+
876
+ // 获取最大值,用于设置 yAxis 的 max
877
+ const numericValues = raw.filter((v) => typeof v === 'number')
878
+ const maxV = numericValues.length > 0 ? Math.max(...numericValues) : 0
879
+
880
+ const titleDrawer = el.getAttribute('data-title') || ''
881
+
882
+ // eslint-disable-next-line no-undef
883
+ const chart = echarts.init(el)
884
+
885
+ chart.setOption({
886
+ tooltip: {
887
+ trigger: 'axis',
888
+ formatter: (params) => {
889
+ const p = Array.isArray(params) ? params[0] : params
890
+ const v = p?.value != null ? Number(p.value) : null
891
+ const endH = window.__overtimeEndHour || 18
892
+
893
+ if (v == null) {
894
+ return `
895
+ <div style="font-size:13px; line-height:1.5">
896
+ <b>${p.axisValue}</b><br/>
897
+ 无数据
898
+ </div>
899
+ `
900
+ }
901
+
902
+ const overtime = Math.max(0, v - endH)
903
+ const overtimeText = overtime.toFixed(2)
904
+
905
+ let level = '🟢 正常(无明显加班)'
906
+ if (overtime >= 1 && overtime < 2) level = '🟠 中度加班(1–2h)'
907
+ if (overtime >= 2) level = '🔴 严重加班(≥2h)'
908
+
909
+ return `
910
+ <div style="font-size:13px; line-height:1.5">
911
+ <b>${p.axisValue}</b><br/>
912
+ 最晚提交时间:<b>${v.toFixed(2)} 点</b><br/>
913
+ 超出下班:<b>${overtimeText} 小时</b><br/>
914
+ 加班等级:${level}
915
+ </div>
916
+ `
917
+ }
918
+ },
919
+ xAxis: { type: 'category', data: labels },
920
+ yAxis: {
921
+ type: 'value',
922
+ min: 0,
923
+ max: Math.max(26, Math.ceil(maxV + 1))
924
+ },
925
+ series: [
926
+ {
927
+ type: 'line',
928
+ name: '每日最晚提交小时',
929
+ data,
930
+ // 让折线在 null 点之间连起来,避免视觉上“断裂”
931
+ connectNulls: true,
932
+ markLine: {
933
+ symbol: ['none', 'arrow'],
934
+ data: [
935
+ // 20 小时线(橙色)
936
+ {
937
+ yAxis: 19,
938
+ lineStyle: {
939
+ color: '#fb8c00',
940
+ width: 2,
941
+ type: 'solid'
942
+ },
943
+ label: {
944
+ formatter: '21h',
945
+ color: '#fb8c00'
946
+ }
947
+ },
948
+ // 21 小时线(红色)
949
+ {
950
+ yAxis: 20,
951
+ lineStyle: {
952
+ color: '#d32f2f',
953
+ width: 2,
954
+ type: 'solid'
955
+ },
956
+ label: {
957
+ formatter: '24h',
958
+ color: '#d32f2f'
959
+ }
960
+ }
961
+ ]
962
+ }
963
+ }
964
+ ]
965
+ })
966
+
967
+ // 点击某一天的最晚提交时间点,打开抽屉显示该日 commits
968
+ if (typeof onDayClick === 'function' && Array.isArray(commits)) {
969
+ // 预聚合:按天收集 commits
970
+ const dayCommitsMap = {}
971
+ commits.forEach((c) => {
972
+ const d = new Date(c.date).toISOString().slice(0, 10)
973
+ if (!dayCommitsMap[d]) dayCommitsMap[d] = []
974
+ dayCommitsMap[d].push(c)
975
+ })
976
+
977
+ chart.on('click', (params) => {
978
+ const idx = params.dataIndex
979
+ const date = labels[idx]
980
+ const list = dayCommitsMap[date] || []
981
+ // onDayClick(date, list.length, list)
982
+ onDayClick({
983
+ date,
984
+ count: list.length,
985
+ commits: list,
986
+ titleDrawer
987
+ })
988
+ })
989
+ }
990
+
991
+ return chart
992
+ }
993
+
994
+ function drawDailySeverity(latestByDay, commits, onDayClick) {
995
+ const el = document.getElementById('dailySeverityChart')
996
+ const isEmpty = hideElementByObj({ el, objectName: latestByDay })
997
+ if (isEmpty) {
998
+ return null
999
+ }
1000
+ if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
1001
+
1002
+ const labels = latestByDay.map((d) => d.date)
1003
+ const endH = window.__overtimeEndHour || 18
1004
+
1005
+ const raw = latestByDay.map((d) =>
1006
+ typeof d.latestHourNormalized === 'number'
1007
+ ? d.latestHourNormalized
1008
+ : (d.latestHour ?? null)
1009
+ )
1010
+
1011
+ // 若某天 latestHourNormalized 为空,表示「没有下班后到次日上班前的提交」,
1012
+ // 这里按 0 小时加班处理,保证折线连续。
1013
+ const sev = raw.map((v) => (v == null ? 0 : Math.max(0, Number(v) - endH)))
1014
+
1015
+ const titleDrawer = el.getAttribute('data-title') || ''
1016
+
1017
+ // eslint-disable-next-line no-undef
1018
+ const chart = echarts.init(el)
1019
+
1020
+ chart.setOption({
1021
+ tooltip: {
1022
+ trigger: 'axis',
1023
+ formatter: (params) => {
1024
+ const p = params[0]
1025
+ if (!p) return ''
1026
+ const date = p.axisValue
1027
+ const overtime = p.data
1028
+ const rawHour = raw[p.dataIndex] // 原始 latestHour 或 latestHourNormalized
1029
+
1030
+ return `
1031
+ <div style="font-size:13px;">
1032
+ <b>${date}</b><br/>
1033
+ 下班后:<b>${overtime.toFixed(2)} 小时</b><br/>
1034
+ 原始最晚提交:${rawHour != null ? `${rawHour.toFixed(2)} 点` : '无'}<br/>
1035
+ 加班等级:${
1036
+ // eslint-disable-next-line no-nested-ternary
1037
+ overtime < 1
1038
+ ? '🟢 0–1 小时(轻度)'
1039
+ : overtime < 2
1040
+ ? '🟠 1–2 小时(中度)'
1041
+ : '🔴 ≥2 小时(严重)'
1042
+ }
1043
+ </div>
1044
+ `
1045
+ }
1046
+ },
1047
+
1048
+ xAxis: { type: 'category', data: labels },
1049
+ yAxis: { type: 'value', min: 0 },
1050
+
1051
+ series: [
1052
+ {
1053
+ type: 'line',
1054
+ name: '超过下班小时数',
1055
+ data: sev,
1056
+ // 连续显示 0 小时加班的日期,避免折线断开
1057
+ connectNulls: true,
1058
+
1059
+ // ⭐ 加班区域背景
1060
+ markArea: {
1061
+ data: [
1062
+ // 0–1h:透明
1063
+ [{ yAxis: 0 }, { yAxis: 1, itemStyle: { color: 'rgba(0,0,0,0)' } }],
1064
+ // 1–2h:半透明橙色
1065
+ [
1066
+ { yAxis: 1 },
1067
+ { yAxis: 2, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } } // #fb8c00
1068
+ ],
1069
+ // ≥2h:半透明红色
1070
+ [
1071
+ { yAxis: 2 },
1072
+ { yAxis: 10, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } } // #d32f2f
1073
+ ]
1074
+ ]
1075
+ },
1076
+
1077
+ // ⭐ 超时阈值标线
1078
+ markLine: {
1079
+ symbol: ['none', 'arrow'],
1080
+ data: [
1081
+ {
1082
+ yAxis: 1,
1083
+ lineStyle: {
1084
+ color: '#fb8c00',
1085
+ width: 2,
1086
+ type: 'dashed'
1087
+ },
1088
+ label: { formatter: '1h', color: '#fb8c00' }
1089
+ },
1090
+ {
1091
+ yAxis: 2,
1092
+ lineStyle: {
1093
+ color: '#d32f2f',
1094
+ width: 2,
1095
+ type: 'dashed'
1096
+ },
1097
+ label: { formatter: '2h', color: '#d32f2f' }
1098
+ }
1099
+ ]
1100
+ }
1101
+ }
1102
+ ]
1103
+ })
1104
+
1105
+ // 点击某一天的「超过下班小时数」点,打开抽屉显示该日 commits
1106
+ if (typeof onDayClick === 'function' && Array.isArray(commits)) {
1107
+ const dayCommitsMap = {}
1108
+ commits.forEach((c) => {
1109
+ const d = new Date(c.date).toISOString().slice(0, 10)
1110
+ if (!dayCommitsMap[d]) dayCommitsMap[d] = []
1111
+ dayCommitsMap[d].push(c)
1112
+ })
1113
+
1114
+ chart.on('click', (params) => {
1115
+ const idx = params.dataIndex
1116
+ const date = labels[idx]
1117
+ const list = dayCommitsMap[date] || []
1118
+ // onDayClick(date, list.length, list)
1119
+ onDayClick({
1120
+ date,
1121
+ count: list.length,
1122
+ commits: list,
1123
+ titleDrawer
1124
+ })
1125
+ })
1126
+ }
1127
+
1128
+ return chart
1129
+ }
1130
+
1131
+ /**
1132
+ * 绘制每日趋势(带加班严重度背景区间)并自动分析最累的日期
1133
+ * @param {Array} commits - 原始提交记录(包含 c.date)
1134
+ * @param {Function} onDayClick - 用户点击某一天时的回调 (date, count) => void
1135
+ */
1136
+ /**
1137
+ * 绘制每日趋势(含严重度背景区间、最累标记、tooltip 明细)
1138
+ */
1139
+ function drawDailyTrendSeverity(commits, weekly, onDayClick) {
1140
+ // ---------- 1. 聚合每日数据 ----------
1141
+ const dayMap = new Map()
1142
+ const dayCommitsDetail = {}
1143
+
1144
+ commits.forEach((c) => {
1145
+ const d = new Date(c.date).toISOString().slice(0, 10)
1146
+
1147
+ // 数量统计
1148
+ dayMap.set(d, (dayMap.get(d) || 0) + 1)
1149
+
1150
+ // 详细信息统计(用于 tooltip 显示)
1151
+ if (!dayCommitsDetail[d]) dayCommitsDetail[d] = []
1152
+ dayCommitsDetail[d].push({
1153
+ author: c.author,
1154
+ time: c.date,
1155
+ msg: c.message
1156
+ })
1157
+ })
1158
+
1159
+ const labels = Array.from(dayMap.keys()).sort()
1160
+ const data = labels.map((l) => dayMap.get(l))
1161
+
1162
+ // ---------- 2. 自动分析「最累的一天」 ----------
1163
+ const maxDailyCount = Math.max(...data)
1164
+ const maxDailyIndex = data.indexOf(maxDailyCount)
1165
+ const mostTiredDay = labels[maxDailyIndex]
1166
+
1167
+ document.getElementById('mostTiredDay').innerHTML =
1168
+ `🔥 最累的一天:<b>${mostTiredDay}</b>(${maxDailyCount} 次提交)`
1169
+
1170
+ // ---------- 3. 自动分析「最累的一周」 ----------
1171
+ let maxWeek = null
1172
+ if (Array.isArray(weekly) && weekly.length > 0) {
1173
+ maxWeek = weekly.reduce((a, b) =>
1174
+ a.outsideWorkCount > b.outsideWorkCount ? a : b
1175
+ )
1176
+ if (maxWeek) {
1177
+ document.getElementById('mostTiredWeek').innerHTML =
1178
+ `🔥 最累的一周:<b>${maxWeek.period}</b>(${maxWeek.outsideWorkCount} 次加班)`
1179
+ }
1180
+ }
1181
+
1182
+ // ---------- 4. 自动分析「最累的月份」 ----------
1183
+ const monthMap = new Map()
1184
+ commits.forEach((c) => {
1185
+ const d = new Date(c.date)
1186
+ const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
1187
+ monthMap.set(ym, (monthMap.get(ym) || 0) + 1)
1188
+ })
1189
+
1190
+ const mostTiredMonth = Array.from(monthMap.entries()).sort(
1191
+ (a, b) => b[1] - a[1]
1192
+ )[0]
1193
+
1194
+ document.getElementById('mostTiredMonth').innerHTML =
1195
+ `🔥 最累的月份:<b>${mostTiredMonth[0]}</b>(${mostTiredMonth[1]} 次提交)`
1196
+
1197
+ // ---------- 5. 背景严重度区块 ----------
1198
+ const markArea = {
1199
+ silent: true,
1200
+ itemStyle: { opacity: 0.15 },
1201
+ data: [
1202
+ [{ name: '0–5 次', yAxis: 0 }, { yAxis: 5 }],
1203
+ [
1204
+ { name: '5–10 次', yAxis: 5 },
1205
+ { yAxis: 10, itemStyle: { color: 'orange', opacity: 0.25 } }
1206
+ ],
1207
+ [
1208
+ { name: '10 次以上', yAxis: 10 },
1209
+ { yAxis: 999, itemStyle: { color: 'red', opacity: 0.25 } }
1210
+ ]
1211
+ ]
1212
+ }
1213
+
1214
+ // ---------- 6. 构造 tooltip ----------
1215
+ const tooltipFormatter = (params) => {
1216
+ const date = params?.[0].name
1217
+ const count = params?.[0].value
1218
+ const details = dayCommitsDetail[date] || []
1219
+
1220
+ let html = `📅 <b>${date}</b><br/>提交次数:${count}<br/><br/>`
1221
+
1222
+ details.slice(0, 5).forEach((d) => {
1223
+ html += `👤 ${d.author}<br/>🕒 ${d.time}<br/>💬 ${d.msg}<br/><br/>`
1224
+ })
1225
+
1226
+ if (details.length > 5) {
1227
+ html += `(其余 ${details.length - 5} 条已省略)`
1228
+ }
1229
+
1230
+ return html
1231
+ }
1232
+
1233
+ // ---------- 7. 绘图 ----------
1234
+ const el = document.getElementById('dailyTrendChartDog')
1235
+ const titleDrawer = el.getAttribute('data-title') || ''
1236
+
1237
+ const chart = echarts.init(el)
1238
+
1239
+ chart.setOption({
1240
+ tooltip: {
1241
+ trigger: 'axis',
1242
+ formatter: tooltipFormatter,
1243
+ axisPointer: { type: 'shadow' }
1244
+ },
1245
+ xAxis: { type: 'category', data: labels },
1246
+ yAxis: { type: 'value', min: 0 },
1247
+ series: [
1248
+ {
1249
+ type: 'line',
1250
+ name: '每日提交',
1251
+ data,
1252
+ areaStyle: {},
1253
+ markArea,
1254
+ markPoint: {
1255
+ data: [
1256
+ {
1257
+ name: '最累的一天',
1258
+ coord: [mostTiredDay, maxDailyCount],
1259
+ value: maxDailyCount,
1260
+ symbolSize: 70,
1261
+ itemStyle: { color: '#ff4d4f' },
1262
+ label: { formatter: '🔥 最累' }
1263
+ }
1264
+ ]
1265
+ }
1266
+ }
1267
+ ]
1268
+ })
1269
+
1270
+ // ---------- 8. 点击事件 ----------
1271
+ if (typeof onDayClick === 'function') {
1272
+ chart.on('click', (params) => {
1273
+ if (params.componentType === 'series') {
1274
+ const date = labels[params.dataIndex]
1275
+ const count = data[params.dataIndex]
1276
+ // onDayClick(date, count, dayCommitsDetail[date])
1277
+ onDayClick({
1278
+ date,
1279
+ count,
1280
+ commits: dayCommitsDetail[date],
1281
+ titleDrawer
1282
+ })
1283
+ }
1284
+ })
1285
+ }
1286
+
1287
+ return {
1288
+ chart,
1289
+ analysis: {
1290
+ mostTiredDay,
1291
+ mostTiredMonth,
1292
+ mostTiredWeek: maxWeek
1293
+ }
1294
+ }
1295
+ }
1296
+
1297
+ function showDayDetailSidebar({ date, count, commits, titleDrawer }) {
1298
+ const sidebar = document.getElementById('dayDetailSidebar')
1299
+ const backdrop = document.getElementById('sidebarBackdrop')
1300
+ const title = document.getElementById('sidebarTitle')
1301
+ const content = document.getElementById('sidebarContent')
1302
+ const drawerTitleEl = document.getElementById('sidebarDrawerTitle')
1303
+
1304
+ title.innerHTML = `📅 ${date}(${count} 次提交)`
1305
+ drawerTitleEl.innerHTML = titleDrawer || ''
1306
+
1307
+ // 渲染详情
1308
+ content.innerHTML = commits
1309
+ .map(
1310
+ (c) => `
1311
+ <div class="sidebar-item">
1312
+ <div class="sidebar-item-header">
1313
+ <span class="author">👤 ${escapeHtml(c.author || 'unknown')}</span>
1314
+ <span class="time">🕒 ${escapeHtml(c.time || c.date || '')}</span>
1315
+ </div>
1316
+ <div class="sidebar-item-message">${escapeHtml(c.msg || c.message || '')}</div>
1317
+ </div>
1318
+ `
1319
+ )
1320
+ .join('')
1321
+
1322
+ sidebar.classList.add('show')
1323
+ if (backdrop) backdrop.classList.add('show')
1324
+ }
1325
+
1326
+ function renderKpi(stats) {
1327
+ const el = document.getElementById('kpiContent')
1328
+ if (!el || !stats) return
1329
+ const latest = stats.latestCommit
1330
+ const latestHour = stats.latestCommitHour
1331
+
1332
+ // 使用 cutoff + 上下班时间,重新在全部 commits 中计算「加班最晚一次提交」
1333
+ const cutoff = window.__overnightCutoff ?? 6
1334
+ const startHour =
1335
+ typeof stats.startHour === 'number' && stats.startHour >= 0
1336
+ ? stats.startHour
1337
+ : 9
1338
+ const endHour =
1339
+ typeof stats.endHour === 'number' && stats.endHour >= 0
1340
+ ? stats.endHour
1341
+ : (window.__overtimeEndHour ?? 18)
1342
+
1343
+ let latestOut = null
1344
+ let latestOutHour = null
1345
+ let maxSeverity = -1
1346
+
1347
+ if (Array.isArray(commitsAll) && commitsAll.length > 0) {
1348
+ commitsAll.forEach((c) => {
1349
+ const d = new Date(c.date)
1350
+ if (!d || Number.isNaN(d.valueOf())) return
1351
+ const h = d.getHours()
1352
+
1353
+ // 只看「当日下班后」以及「次日凌晨 cutoff 之前,且仍在上班前」的提交
1354
+ let sev = null
1355
+ if (h >= endHour && h < 24) {
1356
+ // 当晚:直接按 h - endHour 计算
1357
+ sev = h - endHour
1358
+ } else if (h >= 0 && h < cutoff && h < startHour) {
1359
+ // 次日凌晨:视作跨天,加上 24
1360
+ sev = 24 - endHour + h
1361
+ }
1362
+
1363
+ if (sev != null && sev >= 0 && sev > maxSeverity) {
1364
+ maxSeverity = sev
1365
+ latestOut = c
1366
+ latestOutHour = h
1367
+ }
1368
+ })
1369
+ }
1370
+
1371
+ // 若按 cutoff 没算出结果,则退回到原来的 stats.latestOutsideCommit
1372
+ if (!latestOut && stats.latestOutsideCommit) {
1373
+ latestOut = stats.latestOutsideCommit
1374
+ latestOutHour =
1375
+ stats.latestOutsideCommitHour ??
1376
+ (latestOut ? new Date(latestOut.date).getHours() : null)
1377
+ }
1378
+
1379
+ const htmlLatest = latest
1380
+ ? `<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>`
1381
+ : ``
1382
+ const html = [
1383
+ htmlLatest,
1384
+ `<div class="hr"></div>`,
1385
+ `<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>`,
1386
+ `<div class="hr"></div>`,
1387
+ `<div>次日归并窗口:凌晨 <b>${cutoff}</b> 点内归前一日</div>`
1388
+ ].join('')
1389
+ el.innerHTML = html
1390
+ }
1391
+
1392
+ // 1) 按小时分组(例:commits 为原始提交数组)
1393
+ function groupCommitsByHour(commits) {
1394
+ const byHour = Array.from({ length: 24 }, () => [])
1395
+ commits.forEach((c) => {
1396
+ // 解析 commit 的本地小时(考虑时区已有 '+0800' 等)
1397
+ const d = new Date(c.date)
1398
+ const h = d.getHours() // 若数据已为 UTC,请按需求调整
1399
+ byHour[h].push(c)
1400
+ })
1401
+ return byHour
1402
+ }
1403
+
1404
+ // 基于 latestByDay + cutoff/endHour 统计「最晚加班的一天 / 一周 / 一月」
1405
+ function computeAndRenderLatestOvertime(latestByDay) {
1406
+ if (!Array.isArray(latestByDay) || latestByDay.length === 0) return
1407
+
1408
+ const endH = window.__overtimeEndHour || 18
1409
+
1410
+ // 每天的 latestHourNormalized → 超出下班的小时数
1411
+ const dailyOvertime = latestByDay
1412
+ .map((d) => {
1413
+ let v = null
1414
+ if (typeof d.latestHourNormalized === 'number') {
1415
+ v = d.latestHourNormalized
1416
+ } else if (typeof d.latestHour === 'number') {
1417
+ v = d.latestHour
1418
+ }
1419
+ if (v == null) return null
1420
+ const overtime = Math.max(0, Number(v) - endH)
1421
+ return { date: d.date, overtime, raw: v }
1422
+ })
1423
+ .filter(Boolean)
1424
+
1425
+ if (!dailyOvertime.length) return
1426
+
1427
+ // 1) 最晚加班的一天(超出下班小时数最大,若相同取日期更晚)
1428
+ const dailySorted = [...dailyOvertime].sort((a, b) => {
1429
+ if (b.overtime !== a.overtime) return b.overtime - a.overtime
1430
+ return new Date(b.date) - new Date(a.date)
1431
+ })
1432
+ const worstDay = dailySorted[0]
1433
+ const dayEl = document.getElementById('latestOvertimeDay')
1434
+ if (dayEl) {
1435
+ dayEl.innerHTML = `⏰ 最晚加班的一天:<b>${worstDay.date}</b>(超过下班 <b>${worstDay.overtime.toFixed(
1436
+ 2
1437
+ )}</b> 小时,逻辑时间约 ${worstDay.raw.toFixed(2)} 点)`
1438
+ }
1439
+
1440
+ // 2) 按周聚合:每周取「该周内任意一天的最大加班时长」
1441
+ const weekMap = new Map()
1442
+ dailyOvertime.forEach((d) => {
1443
+ const key = getIsoWeekKey(d.date)
1444
+ if (!key) return
1445
+ const cur = weekMap.get(key)
1446
+ if (!cur || d.overtime > cur.overtime) {
1447
+ weekMap.set(key, d)
1448
+ }
1449
+ })
1450
+
1451
+ if (weekMap.size) {
1452
+ const weeks = Array.from(weekMap.entries()).sort((a, b) => {
1453
+ if (b[1].overtime !== a[1].overtime) return b[1].overtime - a[1].overtime
1454
+ return new Date(b[1].date) - new Date(a[1].date)
1455
+ })
1456
+ const [weekKey, weekInfo] = weeks[0]
1457
+ const weekEl = document.getElementById('latestOvertimeWeek')
1458
+ if (weekEl) {
1459
+ weekEl.innerHTML = `⏰ 最晚加班的一周:<b>${weekKey}</b>(代表日期 ${weekInfo.date},超过下班 <b>${weekInfo.overtime.toFixed(
1460
+ 2
1461
+ )}</b> 小时)`
1462
+ }
1463
+ }
1464
+
1465
+ // 3) 按月聚合:每月取「该月任意一天的最大加班时长」
1466
+ const monthMap = new Map()
1467
+ dailyOvertime.forEach((d) => {
1468
+ const dt = new Date(d.date)
1469
+ if (Number.isNaN(dt.valueOf())) return
1470
+ const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(
1471
+ 2,
1472
+ '0'
1473
+ )}`
1474
+ const cur = monthMap.get(key)
1475
+ if (!cur || d.overtime > cur.overtime) {
1476
+ monthMap.set(key, d)
1477
+ }
1478
+ })
1479
+
1480
+ if (monthMap.size) {
1481
+ const months = Array.from(monthMap.entries()).sort((a, b) => {
1482
+ if (b[1].overtime !== a[1].overtime) return b[1].overtime - a[1].overtime
1483
+ return new Date(b[1].date) - new Date(a[1].date)
1484
+ })
1485
+ const [monthKey, monthInfo] = months[0]
1486
+ const monthEl = document.getElementById('latestOvertimeMonth')
1487
+ if (monthEl) {
1488
+ monthEl.innerHTML = `⏰ 最晚加班的月份:<b>${monthKey}</b>(代表日期 ${monthInfo.date},超过下班 <b>${monthInfo.overtime.toFixed(
1489
+ 2
1490
+ )}</b> 小时)`
1491
+ }
1492
+ }
1493
+ }
1494
+
1495
+ function buildDataset(stats, type) {
1496
+ const dataMap = stats[type] // { author: { period: changed } }
1497
+
1498
+ const authors = Object.keys(dataMap)
1499
+ const allPeriods = Array.from(
1500
+ new Set(authors.flatMap((a) => Object.keys(dataMap[a])))
1501
+ ).sort()
1502
+
1503
+ const series = authors.map((a) => ({
1504
+ name: a,
1505
+ type: 'line',
1506
+ smooth: true,
1507
+ data: allPeriods.map((p) => dataMap[a][p] || 0)
1508
+ }))
1509
+
1510
+ return { authors, allPeriods, series }
1511
+ }
1512
+
1513
+ const drawChangeTrends = (stats) => {
1514
+ const el = document.getElementById('chartAuthorChanges')
1515
+ if (!el) return null
1516
+ const chart = echarts.init(el)
1517
+
1518
+ function render(type) {
1519
+ const { authors, allPeriods, series } = buildDataset(stats, type)
1520
+ const ds = { authors, allPeriods, series }
1521
+ ds.rangeMap = {}
1522
+
1523
+ for (const period of ds.allPeriods) {
1524
+ if (period.includes('-W')) {
1525
+ const [yy, ww] = period.split('-W')
1526
+ ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
1527
+ }
1528
+ }
1529
+ chart.setOption({
1530
+ // tooltip: { trigger: 'axis' },
1531
+ tooltip: {
1532
+ trigger: 'axis',
1533
+ formatter(params) {
1534
+ if (!params || !params.length) return ''
1535
+
1536
+ const p = params[0]
1537
+ const label = p.axisValue
1538
+ const isWeekly = type === 'weekly'
1539
+
1540
+ let extra = ''
1541
+ if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
1542
+ const { start, end } = ds.rangeMap[label]
1543
+ // extra = `<div style="margin-top:4px;color:#999;font-size:12px">
1544
+ // 周区间:${start} ~ ${end}
1545
+ // </div>`
1546
+ extra = ''
1547
+ }
1548
+
1549
+ const lines = params
1550
+ .filter((i) => i.data > 0)
1551
+ .sort((a, b) => (b.data || 0) - (a.data || 0) || String(a.seriesName).localeCompare(String(b.seriesName)))
1552
+ .map(
1553
+ (item) => `${item.marker}${item.seriesName}: ${item.data} 行变更`
1554
+ )
1555
+ .join('<br/>')
1556
+
1557
+ return `
1558
+ <div>${label}</div>
1559
+ ${extra}
1560
+ ${lines}
1561
+ `
1562
+ }
1563
+ },
1564
+ legend: { data: authors },
1565
+ xAxis: { type: 'category', data: allPeriods },
1566
+ yAxis: { type: 'value' },
1567
+ series
1568
+ })
1569
+ }
1570
+
1571
+ // 初次渲染:日
1572
+ render('daily')
1573
+
1574
+ // tabs 切换
1575
+ const tabs = document.querySelectorAll('#tabs button')
1576
+ tabs.forEach((btnEl) => {
1577
+ btnEl.addEventListener('click', () => {
1578
+ tabs.forEach((b) => b.classList.remove('active'))
1579
+ btnEl.classList.add('active')
1580
+ render(btnEl.dataset.type)
1581
+ })
1582
+ })
1583
+
1584
+ // 点击事件:点击某个作者在某个周期的点,打开侧栏显示该作者在该周期的 commits
1585
+ chart.on('click', (p) => {
1586
+ try {
1587
+ if (!p || p.componentType !== 'series') return
1588
+ const label = p.axisValue || p.name
1589
+ const author = p.seriesName
1590
+ if (!label || !author) return
1591
+ const type = document.querySelector('#tabs button.active')?.dataset.type || 'daily'
1592
+
1593
+ const filteredCommits = (Array.isArray(commitsAll) ? commitsAll : []).filter((c) => {
1594
+ const a = c.author || 'unknown'
1595
+ if (a !== author) return false
1596
+ const d = new Date(c.date)
1597
+ if (Number.isNaN(d.valueOf())) return false
1598
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
1599
+ if (type === 'weekly') {
1600
+ if (!label.includes('-W')) return false
1601
+ const [yy, ww] = label.split('-W')
1602
+ const range = getISOWeekRange(Number(yy), Number(ww))
1603
+ const day = d.toISOString().slice(0, 10)
1604
+ return day >= range.start && day <= range.end
1605
+ }
1606
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
1607
+ return month === label
1608
+ })
1609
+
1610
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
1611
+
1612
+ if (type === 'weekly') {
1613
+ const weeklyItem = { outsideWorkCount: filteredCommits.length, outsideWorkRate: 0 }
1614
+ showSideBarForWeek({ period: label, weeklyItem, commits: filteredCommits, titleDrawer: `${author} 变更量 ${type} 详情` })
1615
+ } else {
1616
+ showDayDetailSidebar({ date: label, count: filteredCommits.length, commits: filteredCommits, titleDrawer: `${author} 变更量 ${type} 详情` })
1617
+ }
1618
+ } catch (err) {
1619
+ console.warn('Change chart click handler error', err)
1620
+ }
1621
+ })
1622
+
1623
+ return chart
1624
+ }
1625
+
1626
+ // ========= 开发者加班趋势(基于 commits 现场计算) =========
1627
+ function buildAuthorOvertimeDataset(commits, type, startHour, endHour, cutoff) {
1628
+ const byAuthor = new Map()
1629
+ const periods = new Set()
1630
+
1631
+ commits.forEach((c) => {
1632
+ const d = new Date(c.date)
1633
+ if (Number.isNaN(d.valueOf())) return
1634
+ const h = d.getHours()
1635
+ const isOvertime =
1636
+ (h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
1637
+ if (!isOvertime) return
1638
+
1639
+ let key
1640
+ if (type === 'daily') {
1641
+ key = d.toISOString().slice(0, 10)
1642
+ } else if (type === 'weekly') {
1643
+ key = getIsoWeekKey(d.toISOString().slice(0, 10))
1644
+ } else {
1645
+ key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
1646
+ }
1647
+ if (!key) return
1648
+ periods.add(key)
1649
+
1650
+ const author = c.author || 'unknown'
1651
+ if (!byAuthor.has(author)) byAuthor.set(author, {})
1652
+ const obj = byAuthor.get(author)
1653
+ obj[key] = (obj[key] || 0) + 1
1654
+ })
1655
+
1656
+ const allPeriods = Array.from(periods).sort()
1657
+ const authors = Array.from(byAuthor.keys()).sort()
1658
+ const series = authors.map((a) => ({
1659
+ name: a,
1660
+ type: 'line',
1661
+ smooth: true,
1662
+ data: allPeriods.map((p) => byAuthor.get(a)[p] || 0)
1663
+ }))
1664
+ return { authors, allPeriods, series }
1665
+ }
1666
+
1667
+ function drawAuthorOvertimeTrends(commits, stats) {
1668
+ const el = document.getElementById('chartAuthorOvertime')
1669
+ if (!el) return null
1670
+ const chart = echarts.init(el)
1671
+
1672
+ const startHour =
1673
+ typeof stats.startHour === 'number' && stats.startHour >= 0
1674
+ ? stats.startHour
1675
+ : 9
1676
+ const endHour =
1677
+ typeof stats.endHour === 'number' && stats.endHour >= 0
1678
+ ? stats.endHour
1679
+ : window.__overtimeEndHour || 18
1680
+ const cutoff = window.__overnightCutoff ?? 6
1681
+
1682
+ function render(type) {
1683
+ const ds = buildAuthorOvertimeDataset(
1684
+ commits,
1685
+ type,
1686
+ startHour,
1687
+ endHour,
1688
+ cutoff
1689
+ )
1690
+ ds.rangeMap = {}
1691
+
1692
+ for (const period of ds.allPeriods) {
1693
+ if (period.includes('-W')) {
1694
+ const [yy, ww] = period.split('-W')
1695
+ ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
1696
+ }
1697
+ }
1698
+ chart.setOption({
1699
+ tooltip: {
1700
+ trigger: 'axis',
1701
+ formatter(params) {
1702
+ if (!params || !params.length) return ''
1703
+
1704
+ const p = params[0]
1705
+ const label = p.axisValue
1706
+ const isWeekly = type === 'weekly'
1707
+
1708
+ let extra = ''
1709
+ if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
1710
+ const { start, end } = ds.rangeMap[label]
1711
+ extra = `<div style="margin-top:4px;color:#999;font-size:12px">
1712
+ 周区间:${start} ~ ${end}
1713
+ </div>`
1714
+ }
1715
+
1716
+ const lines = params
1717
+ .filter((i) => i.data > 0)
1718
+ .sort((a, b) => (b.data || 0) - (a.data || 0) || String(a.seriesName).localeCompare(String(b.seriesName)))
1719
+ .map(
1720
+ (item) => `${item.marker}${item.seriesName}: ${item.data} 次提交`
1721
+ )
1722
+ .join('<br/>')
1723
+
1724
+ return `
1725
+ <div>${label}</div>
1726
+ ${extra}
1727
+ ${lines}
1728
+ `
1729
+ }
1730
+ },
1731
+ legend: { data: ds.authors },
1732
+ xAxis: { type: 'category', data: ds.allPeriods },
1733
+ // 把 y 轴名称改为提交数
1734
+ yAxis: { type: 'value', name: '提交数 (次)' },
1735
+
1736
+ series: ds.series
1737
+ })
1738
+ }
1739
+
1740
+ // 初始按日
1741
+ render('daily')
1742
+
1743
+ // tabs 切换
1744
+ const tabs = document.querySelectorAll('#tabsOvertime button')
1745
+ tabs.forEach((btnEl) => {
1746
+ btnEl.addEventListener('click', () => {
1747
+ tabs.forEach((b) => b.classList.remove('active'))
1748
+ btnEl.classList.add('active')
1749
+ render(btnEl.dataset.type)
1750
+ })
1751
+ })
1752
+
1753
+ // 输出本周风险与加班时长排行
1754
+ renderWeeklyRiskSummary(commits, { startHour, endHour, cutoff })
1755
+ renderMonthlyRiskSummary(commits, { startHour, endHour, cutoff })
1756
+ // 新增:本周/本月加班时长排名(显示所有作者总时长,前三名带图标,状元标注“夜魔侠”)
1757
+ renderWeeklyDurationRankSummary(commits, { startHour, endHour, cutoff })
1758
+ renderWeeklyDurationRiskSummary(commits, { startHour, endHour, cutoff })
1759
+ renderMonthlyDurationRankSummary(commits, { startHour, endHour, cutoff })
1760
+ renderMonthlyDurationRiskSummary(commits, { startHour, endHour, cutoff })
1761
+ renderRolling30DurationRiskSummary(commits, { startHour, endHour, cutoff })
1762
+
1763
+ // 点击事件:点击某个作者在某周期的点,打开侧栏显示该作者在该周期内的下班后提交(加班)明细
1764
+ chart.on('click', (p) => {
1765
+ try {
1766
+ if (!p || p.componentType !== 'series') return
1767
+ const label = p.axisValue || p.name
1768
+ const author = p.seriesName
1769
+ if (!label || !author) return
1770
+ const type = document.querySelector('#tabsOvertime button.active')?.dataset.type || 'daily'
1771
+
1772
+ const filteredCommits = commits.filter((c) => {
1773
+ const a = c.author || 'unknown'
1774
+ if (a !== author) return false
1775
+ const d = new Date(c.date)
1776
+ if (Number.isNaN(d.valueOf())) return false
1777
+ const h = d.getHours()
1778
+ const isOT = (h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
1779
+ if (!isOT) return false
1780
+
1781
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
1782
+ if (type === 'weekly') {
1783
+ if (!label.includes('-W')) return false
1784
+ const [yy, ww] = label.split('-W')
1785
+ const range = getISOWeekRange(Number(yy), Number(ww))
1786
+ const day = d.toISOString().slice(0, 10)
1787
+ return day >= range.start && day <= range.end
1788
+ }
1789
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
1790
+ return month === label
1791
+ })
1792
+
1793
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
1794
+
1795
+ if (type === 'weekly') {
1796
+ const weeklyItem = { outsideWorkCount: filteredCommits.length, outsideWorkRate: 0 }
1797
+ showSideBarForWeek({ period: label, weeklyItem, commits: filteredCommits, titleDrawer: `${author} 加班本周详情` })
1798
+ } else {
1799
+ showDayDetailSidebar({ date: label, count: filteredCommits.length, commits: filteredCommits, titleDrawer: `${author} 加班 ${type} 详情` })
1800
+ }
1801
+ } catch (err) {
1802
+ console.warn('Overtime chart click handler error', err)
1803
+ }
1804
+ })
1805
+
1806
+ return chart
1807
+ }
1808
+
1809
+ function renderWeeklyRiskSummary(
1810
+ commits,
1811
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
1812
+ ) {
1813
+ const box = document.getElementById('weeklyRiskSummary')
1814
+ if (!box) return
1815
+
1816
+ // 获取当前周与上一周 key
1817
+ const now = new Date()
1818
+ const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
1819
+ const prev = new Date(now)
1820
+ prev.setDate(prev.getDate() - 7)
1821
+ const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
1822
+
1823
+ // 统计:每周 -> author -> count;同时统计每周日期集合
1824
+ const weekAuthor = new Map()
1825
+ const weekDatesByAuthor = new Map() // week -> author -> Set(date)
1826
+
1827
+ commits.forEach((c) => {
1828
+ const d = new Date(c.date)
1829
+ if (Number.isNaN(d.valueOf())) return
1830
+ const h = d.getHours()
1831
+ const isOT =
1832
+ (h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
1833
+ if (!isOT) return
1834
+
1835
+ const key = getIsoWeekKey(d.toISOString().slice(0, 10))
1836
+ if (!key) return
1837
+ const author = c.author || 'unknown'
1838
+
1839
+ if (!weekAuthor.has(key)) weekAuthor.set(key, new Map())
1840
+ const m = weekAuthor.get(key)
1841
+ m.set(author, (m.get(author) || 0) + 1)
1842
+
1843
+ if (!weekDatesByAuthor.has(key)) weekDatesByAuthor.set(key, new Map())
1844
+ const dMap = weekDatesByAuthor.get(key)
1845
+ if (!dMap.has(author)) dMap.set(author, new Set())
1846
+ dMap.get(author).add(d.toISOString().slice(0, 10))
1847
+ })
1848
+
1849
+ const curMap = weekAuthor.get(curKey) || new Map()
1850
+ const prevMap = weekAuthor.get(prevKey) || new Map()
1851
+ const curTotal = Array.from(curMap.values()).reduce((a, b) => a + b, 0)
1852
+ const prevTotal = Array.from(prevMap.values()).reduce((a, b) => a + b, 0)
1853
+ const delta =
1854
+ prevTotal > 0
1855
+ ? Math.round(((curTotal - prevTotal) / prevTotal) * 100)
1856
+ : null
1857
+
1858
+ // 找当前周最“活跃”的人(加班提交最多),并统计他加班的自然日数
1859
+ let topAuthor = null
1860
+ let topCount = -1
1861
+ curMap.forEach((v, k) => {
1862
+ if (v > topCount) {
1863
+ topCount = v
1864
+ topAuthor = k
1865
+ }
1866
+ })
1867
+ const curDatesMap = weekDatesByAuthor.get(curKey) || new Map()
1868
+ const topDays =
1869
+ topAuthor && curDatesMap.get(topAuthor)
1870
+ ? curDatesMap.get(topAuthor).size
1871
+ : 0
1872
+
1873
+ // 文案
1874
+ const lines = []
1875
+ lines.push('【本周风险总结】')
1876
+
1877
+ if (curTotal === 0) {
1878
+ lines.push('团队本周暂无加班提交。')
1879
+ } else if (delta === null) {
1880
+ lines.push(`团队本周加班提交 ${curTotal} 次。`)
1881
+ } else {
1882
+ const trend = delta >= 0 ? '上升' : '下降'
1883
+ lines.push(`团队加班${trend} ${Math.abs(delta)}%(vs 上周)。`)
1884
+ }
1885
+
1886
+ if (topAuthor && curTotal > 0) {
1887
+ const pct = Math.round((topCount / curTotal) * 100)
1888
+ lines.push(
1889
+ `${topAuthor} 夜间活跃度 ${pct}%,${topDays} 天出现下班后提交(${endHour}:00 后或次日 ${cutoff}:00 前)。`
1890
+ )
1891
+ }
1892
+
1893
+ box.innerHTML = `
1894
+ <div class="risk-summary">
1895
+ <div class="risk-title">【本周风险总结】</div>
1896
+ <ul>
1897
+ ${lines
1898
+ .slice(1)
1899
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
1900
+ .join('')}
1901
+ </ul>
1902
+ </div>
1903
+ `
1904
+ }
1905
+
1906
+ function computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff) {
1907
+ const byAuthorDay = new Map()
1908
+ commits.forEach((c) => {
1909
+ const d = new Date(c.date)
1910
+ if (Number.isNaN(d.valueOf())) return
1911
+ const h = d.getHours()
1912
+ let overtime = null
1913
+ let dayKey = null
1914
+ if (h >= endHour && h < 24) {
1915
+ overtime = h - endHour
1916
+ dayKey = d.toISOString().slice(0, 10)
1917
+ } else if (h >= 0 && h < cutoff && h < startHour) {
1918
+ overtime = 24 - endHour + h
1919
+ const cur = new Date(
1920
+ Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
1921
+ )
1922
+ cur.setUTCDate(cur.getUTCDate() - 1)
1923
+ dayKey = cur.toISOString().slice(0, 10)
1924
+ }
1925
+ if (overtime == null || !dayKey) return
1926
+ const author = c.author || 'unknown'
1927
+ if (!byAuthorDay.has(author)) byAuthorDay.set(author, new Map())
1928
+ const m = byAuthorDay.get(author)
1929
+ const cur = m.get(dayKey)
1930
+ if (!cur || overtime > cur) m.set(dayKey, overtime)
1931
+ })
1932
+ return byAuthorDay
1933
+ }
1934
+
1935
+ function renderWeeklyDurationRankSummary(commits, { startHour = 9, endHour = 18, cutoff = 6 } = {}) {
1936
+ const box = document.getElementById('weeklyDurationRankSummary')
1937
+ if (!box) return
1938
+ const now = new Date()
1939
+ const curWeek = getIsoWeekKey(now.toISOString().slice(0, 10))
1940
+ const byAuthorDay = computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff)
1941
+ const ranks = []
1942
+ byAuthorDay.forEach((dayMap, author) => {
1943
+ let total = 0
1944
+ dayMap.forEach((v, dayKey) => {
1945
+ const wk = getIsoWeekKey(dayKey)
1946
+ if (wk === curWeek) total += v
1947
+ })
1948
+ if (total > 0) ranks.push({ author, total })
1949
+ })
1950
+ ranks.sort((a, b) => b.total - a.total || String(a.author).localeCompare(String(b.author)))
1951
+
1952
+ const lines = []
1953
+ lines.push('【本周加班时长排名】')
1954
+ if (ranks.length === 0) {
1955
+ lines.push('本周暂无加班时长。')
1956
+ } else {
1957
+ ranks.forEach((r, idx) => {
1958
+ const rank = idx + 1
1959
+ const medal = rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
1960
+ const title = rank === 1 ? '(状元・夜魔侠)' : ''
1961
+ lines.push(`${rank}. ${medal}${r.author} — ${r.total.toFixed(2)} 小时${title}`)
1962
+ })
1963
+ }
1964
+ box.innerHTML = `
1965
+ <div class="risk-summary">
1966
+ <div class="risk-title">【本周加班时长排名】</div>
1967
+ <ul>
1968
+ ${lines
1969
+ .slice(1)
1970
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
1971
+ .join('')}
1972
+ </ul>
1973
+ </div>
1974
+ `
1975
+ }
1976
+
1977
+ function renderWeeklyDurationRiskSummary(
1978
+ commits,
1979
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
1980
+ ) {
1981
+ const box = document.getElementById('weeklyDurationRiskSummary')
1982
+ if (!box) return
1983
+ const now = new Date()
1984
+ const curWeek = getIsoWeekKey(now.toISOString().slice(0, 10))
1985
+ const byAuthorDay = computeAuthorDailyMaxOvertime(
1986
+ commits,
1987
+ startHour,
1988
+ endHour,
1989
+ cutoff
1990
+ )
1991
+ const sums = []
1992
+ byAuthorDay.forEach((dayMap, author) => {
1993
+ let total = 0
1994
+ dayMap.forEach((v, dayKey) => {
1995
+ const wk = getIsoWeekKey(dayKey)
1996
+ if (wk === curWeek) total += v
1997
+ })
1998
+ if (total > 0) sums.push({ author, total })
1999
+ })
2000
+ sums.sort((a, b) => b.total - a.total)
2001
+ const top = sums.slice(0, 6)
2002
+ const lines = []
2003
+ lines.push('【本周加班时长风险】')
2004
+ if (top.length === 0) {
2005
+ lines.push('本周暂无加班时长风险。')
2006
+ } else {
2007
+ top.forEach(({ author, total }) => {
2008
+ let level = '轻度'
2009
+ if (total >= 12) level = '严重'
2010
+ else if (total >= 6) level = '中度'
2011
+ lines.push(
2012
+ `${author} 本周累计加班 ${total.toFixed(2)} 小时(${level})。`
2013
+ )
2014
+ })
2015
+ }
2016
+ box.innerHTML = `
2017
+ <div class="risk-summary">
2018
+ <div class="risk-title">【本周加班时长风险】</div>
2019
+ <ul>
2020
+ ${lines
2021
+ .slice(1)
2022
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2023
+ .join('')}
2024
+ </ul>
2025
+ </div>
2026
+ `
2027
+ }
2028
+
2029
+ function renderMonthlyDurationRankSummary(commits, { startHour = 9, endHour = 18, cutoff = 6 } = {}) {
2030
+ const box = document.getElementById('monthlyDurationRankSummary')
2031
+ if (!box) return
2032
+ const now = new Date()
2033
+ const curMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
2034
+ const byAuthorDay = computeAuthorDailyMaxOvertime(commits, startHour, endHour, cutoff)
2035
+ const ranks = []
2036
+ byAuthorDay.forEach((dayMap, author) => {
2037
+ let total = 0
2038
+ dayMap.forEach((v, dayKey) => {
2039
+ const m = dayKey.slice(0, 7)
2040
+ if (m === curMonth) total += v
2041
+ })
2042
+ if (total > 0) ranks.push({ author, total })
2043
+ })
2044
+ ranks.sort((a, b) => b.total - a.total || String(a.author).localeCompare(String(b.author)))
2045
+
2046
+ const lines = []
2047
+ lines.push('【本月加班时长排名】')
2048
+ if (ranks.length === 0) {
2049
+ lines.push('本月暂无加班时长。')
2050
+ } else {
2051
+ ranks.forEach((r, idx) => {
2052
+ const rank = idx + 1
2053
+ const medal = rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
2054
+ const title = rank === 1 ? '(状元・夜魔侠)' : ''
2055
+ lines.push(`${rank}. ${medal}${r.author} — ${r.total.toFixed(2)} 小时${title}`)
2056
+ })
2057
+ }
2058
+ box.innerHTML = `
2059
+ <div class="risk-summary">
2060
+ <div class="risk-title">【本月加班时长排名】</div>
2061
+ <ul>
2062
+ ${lines
2063
+ .slice(1)
2064
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2065
+ .join('')}
2066
+ </ul>
2067
+ </div>
2068
+ `
2069
+ }
2070
+
2071
+ function renderMonthlyDurationRiskSummary(
2072
+ commits,
2073
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2074
+ ) {
2075
+ const box = document.getElementById('monthlyDurationRiskSummary')
2076
+ if (!box) return
2077
+ const now = new Date()
2078
+ const curMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
2079
+ const byAuthorDay = computeAuthorDailyMaxOvertime(
2080
+ commits,
2081
+ startHour,
2082
+ endHour,
2083
+ cutoff
2084
+ )
2085
+ const sums = []
2086
+ byAuthorDay.forEach((dayMap, author) => {
2087
+ let total = 0
2088
+ dayMap.forEach((v, dayKey) => {
2089
+ const m = dayKey.slice(0, 7)
2090
+ if (m === curMonth) total += v
2091
+ })
2092
+ if (total > 0) sums.push({ author, total })
2093
+ })
2094
+ sums.sort((a, b) => b.total - a.total)
2095
+ const top = sums.slice(0, 6)
2096
+ const lines = []
2097
+ lines.push('【本月加班时长风险】')
2098
+ if (top.length === 0) {
2099
+ lines.push('本月暂无加班时长风险。')
2100
+ } else {
2101
+ top.forEach(({ author, total }) => {
2102
+ let level = '轻度'
2103
+ if (total >= 20) level = '严重'
2104
+ else if (total >= 10) level = '中度'
2105
+ lines.push(
2106
+ `${author} 本月累计加班 ${total.toFixed(2)} 小时(${level})。`
2107
+ )
2108
+ })
2109
+ }
2110
+ box.innerHTML = `
2111
+ <div class="risk-summary">
2112
+ <div class="risk-title">【本月加班时长风险】</div>
2113
+ <ul>
2114
+ ${lines
2115
+ .slice(1)
2116
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2117
+ .join('')}
2118
+ </ul>
2119
+ </div>
2120
+ `
2121
+ }
2122
+
2123
+ function renderRolling30DurationRiskSummary(
2124
+ commits,
2125
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2126
+ ) {
2127
+ const box = document.getElementById('rolling30DurationRiskSummary')
2128
+ if (!box) return
2129
+ const now = new Date()
2130
+ const utcToday = new Date(
2131
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
2132
+ )
2133
+ utcToday.setUTCDate(utcToday.getUTCDate() - 29)
2134
+ const startKey = utcToday.toISOString().slice(0, 10)
2135
+
2136
+ const byAuthorDay = computeAuthorDailyMaxOvertime(
2137
+ commits,
2138
+ startHour,
2139
+ endHour,
2140
+ cutoff
2141
+ )
2142
+ const sums = []
2143
+ byAuthorDay.forEach((dayMap, author) => {
2144
+ let total = 0
2145
+ dayMap.forEach((v, dayKey) => {
2146
+ if (dayKey >= startKey) total += v
2147
+ })
2148
+ if (total > 0) sums.push({ author, total })
2149
+ })
2150
+ sums.sort((a, b) => b.total - a.total)
2151
+ const top = sums.slice(0, 6)
2152
+ const lines = []
2153
+ lines.push('【最近30天加班时长风险】')
2154
+ if (top.length === 0) {
2155
+ lines.push('最近30天暂无加班时长风险。')
2156
+ } else {
2157
+ top.forEach(({ author, total }) => {
2158
+ let level = '轻度'
2159
+ if (total >= 20) level = '严重'
2160
+ else if (total >= 10) level = '中度'
2161
+ lines.push(
2162
+ `${author} 最近30天累计加班 ${total.toFixed(2)} 小时(${level})。`
2163
+ )
2164
+ })
2165
+ }
2166
+ box.innerHTML = `
2167
+ <div class="risk-summary">
2168
+ <div class="risk-title">【最近30天加班时长风险】</div>
2169
+ <ul>
2170
+ ${lines
2171
+ .slice(1)
2172
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2173
+ .join('')}
2174
+ </ul>
2175
+ </div>
2176
+ `
2177
+ }
2178
+ function renderMonthlyRiskSummary(
2179
+ commits,
2180
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2181
+ ) {
2182
+ const box = document.getElementById('monthlyRiskSummary')
2183
+ if (!box) return
2184
+
2185
+ const now = new Date()
2186
+ const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
2187
+ const prev = new Date(now)
2188
+ prev.setMonth(prev.getMonth() - 1)
2189
+ const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
2190
+
2191
+ const monthAuthor = new Map()
2192
+ const monthMax = new Map()
2193
+
2194
+ commits.forEach((c) => {
2195
+ const d = new Date(c.date)
2196
+ if (Number.isNaN(d.valueOf())) return
2197
+ const h = d.getHours()
2198
+ const isOT =
2199
+ (h >= endHour && h < 24) || (h >= 0 && h < cutoff && h < startHour)
2200
+ if (!isOT) return
2201
+
2202
+ const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2203
+ const author = c.author || 'unknown'
2204
+
2205
+ if (!monthAuthor.has(key)) monthAuthor.set(key, new Map())
2206
+ const m = monthAuthor.get(key)
2207
+ m.set(author, (m.get(author) || 0) + 1)
2208
+
2209
+ let overtime = null
2210
+ if (h >= endHour && h < 24) overtime = h - endHour
2211
+ else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
2212
+ if (overtime == null) return
2213
+
2214
+ if (!monthMax.has(key)) monthMax.set(key, new Map())
2215
+ const mm = monthMax.get(key)
2216
+ const cur = mm.get(author)
2217
+ const dateStr = d.toISOString().slice(0, 10)
2218
+ if (!cur || overtime > cur.max)
2219
+ mm.set(author, { max: overtime, date: dateStr })
2220
+ })
2221
+
2222
+ const curMap = monthAuthor.get(curKey) || new Map()
2223
+ const prevMap = monthAuthor.get(prevKey) || new Map()
2224
+ const curTotal = Array.from(curMap.values()).reduce((a, b) => a + b, 0)
2225
+ const prevTotal = Array.from(prevMap.values()).reduce((a, b) => a + b, 0)
2226
+ const delta =
2227
+ prevTotal > 0
2228
+ ? Math.round(((curTotal - prevTotal) / prevTotal) * 100)
2229
+ : null
2230
+
2231
+ let topAuthor = null
2232
+ let top = { max: -1, date: null }
2233
+ const curMaxMap = monthMax.get(curKey) || new Map()
2234
+ curMaxMap.forEach((v, k) => {
2235
+ if (v.max > top.max) {
2236
+ top = v
2237
+ topAuthor = k
2238
+ }
2239
+ })
2240
+
2241
+ let prevMax = -1
2242
+ const prevMaxMap = monthMax.get(prevKey) || new Map()
2243
+ prevMaxMap.forEach((v) => {
2244
+ if (v.max > prevMax) prevMax = v.max
2245
+ })
2246
+
2247
+ const lines = []
2248
+ lines.push('【本月加班风险】')
2249
+
2250
+ if (curTotal === 0) {
2251
+ lines.push('本月尚无下班后提交,未发现明显风险。')
2252
+ } else {
2253
+ if (delta === null) {
2254
+ lines.push(`本月下班后提交 ${curTotal} 次。`)
2255
+ } else {
2256
+ const trend = delta >= 0 ? '上升' : '下降'
2257
+ lines.push(`本月下班后提交${trend} ${Math.abs(delta)}%(vs 上月)。`)
2258
+ }
2259
+
2260
+ if (top.max >= 0) {
2261
+ let trend2 = '暂无上月对比'
2262
+ if (prevMax >= 0) {
2263
+ if (top.max > prevMax) trend2 = '较上月更晚'
2264
+ else if (top.max < prevMax) trend2 = '较上月提前'
2265
+ else trend2 = '与上月持平'
2266
+ }
2267
+ lines.push(
2268
+ `${topAuthor} 本月最晚超出下班 ${top.max.toFixed(2)} 小时(${top.date}),${trend2}。`
2269
+ )
2270
+ if (top.max >= 2) lines.push('已超过 2 小时,存在严重加班风险。')
2271
+ else if (top.max >= 1) lines.push('已超过 1 小时,存在中度加班风险。')
2272
+ }
2273
+ }
2274
+
2275
+ box.innerHTML = `
2276
+ <div class="risk-summary">
2277
+ <div class="risk-title">【本月加班风险】</div>
2278
+ <ul>
2279
+ ${lines
2280
+ .slice(1)
2281
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2282
+ .join('')}
2283
+ </ul>
2284
+ </div>
2285
+ `
2286
+ }
2287
+
2288
+ // ========= 开发者加班“最晚”趋势(每期取最大超时) =========
2289
+ function buildAuthorLatestDataset(commits, type, startHour, endHour, cutoff) {
2290
+ const byAuthor = new Map()
2291
+ const periods = new Set()
2292
+
2293
+ commits.forEach((c) => {
2294
+ const d = new Date(c.date)
2295
+ if (Number.isNaN(d.valueOf())) return
2296
+ const h = d.getHours()
2297
+
2298
+ let overtime = null
2299
+ if (h >= endHour && h < 24) overtime = h - endHour
2300
+ else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
2301
+ if (overtime == null) return
2302
+
2303
+ let key
2304
+ if (type === 'daily') {
2305
+ key = d.toISOString().slice(0, 10)
2306
+ } else if (type === 'weekly') {
2307
+ key = getIsoWeekKey(d.toISOString().slice(0, 10))
2308
+ } else {
2309
+ key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2310
+ }
2311
+ if (!key) return
2312
+ periods.add(key)
2313
+
2314
+ const author = c.author || 'unknown'
2315
+ if (!byAuthor.has(author)) byAuthor.set(author, {})
2316
+ const obj = byAuthor.get(author)
2317
+ obj[key] = Math.max(obj[key] || 0, overtime)
2318
+ })
2319
+
2320
+ const allPeriods = Array.from(periods).sort()
2321
+
2322
+ const authors = Array.from(byAuthor.keys()).sort()
2323
+ const series = authors.map((a) => ({
2324
+ name: a,
2325
+ type: 'line',
2326
+ smooth: true,
2327
+ data: allPeriods.map((p) => byAuthor.get(a)[p] || 0)
2328
+ }))
2329
+ return { authors, allPeriods, series }
2330
+ }
2331
+
2332
+ function drawAuthorLatestOvertimeTrends(commits, stats) {
2333
+ const el = document.getElementById('chartAuthorLatestOvertime')
2334
+ if (!el) return null
2335
+ const chart = echarts.init(el)
2336
+
2337
+ const startHour =
2338
+ typeof stats.startHour === 'number' && stats.startHour >= 0
2339
+ ? stats.startHour
2340
+ : 9
2341
+ const endHour =
2342
+ typeof stats.endHour === 'number' && stats.endHour >= 0
2343
+ ? stats.endHour
2344
+ : window.__overtimeEndHour || 18
2345
+ const cutoff = window.__overnightCutoff ?? 6
2346
+
2347
+ function render(type) {
2348
+ const ds = buildAuthorLatestDataset(
2349
+ commits,
2350
+ type,
2351
+ startHour,
2352
+ endHour,
2353
+ cutoff
2354
+ )
2355
+ ds.rangeMap = {}
2356
+
2357
+ for (const period of ds.allPeriods) {
2358
+ if (period.includes('-W')) {
2359
+ const [yy, ww] = period.split('-W')
2360
+ ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
2361
+ }
2362
+ }
2363
+ chart.setOption({
2364
+ tooltip: {
2365
+ trigger: 'axis',
2366
+ formatter(params) {
2367
+ if (!params || !params.length) return ''
2368
+
2369
+ const p = params[0]
2370
+ const label = p.axisValue
2371
+ const isWeekly = type === 'weekly'
2372
+
2373
+ let extra = ''
2374
+ if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
2375
+ const { start, end } = ds.rangeMap[label]
2376
+ extra = `<div style="margin-top:4px;color:#999;font-size:12px">
2377
+ 周区间:${start} ~ ${end}
2378
+ </div>`
2379
+ }
2380
+
2381
+ const lines = params
2382
+ .filter((i) => i.data > 0)
2383
+ .sort((a, b) => (b.data || 0) - (a.data || 0) || String(a.seriesName).localeCompare(String(b.seriesName)))
2384
+ .map(
2385
+ (item) => `${item.marker}${item.seriesName}: ${item.data} 小时`
2386
+ )
2387
+ .join('<br/>')
2388
+
2389
+ return `
2390
+ <div>${label}</div>
2391
+ ${extra}
2392
+ ${lines}
2393
+ `
2394
+ }
2395
+ },
2396
+ legend: { data: ds.authors },
2397
+ xAxis: { type: 'category', data: ds.allPeriods },
2398
+ yAxis: {
2399
+ type: 'value',
2400
+ name: '超出下班(小时)',
2401
+ min: 0
2402
+ },
2403
+ series: ds.series
2404
+ })
2405
+ }
2406
+
2407
+ render('daily')
2408
+
2409
+ const tabs = document.querySelectorAll('#tabsLatestOvertime button')
2410
+ tabs.forEach((btnEl) => {
2411
+ btnEl.addEventListener('click', () => {
2412
+ tabs.forEach((b) => b.classList.remove('active'))
2413
+ btnEl.classList.add('active')
2414
+ render(btnEl.dataset.type)
2415
+ })
2416
+ })
2417
+
2418
+ renderLatestRiskSummary(commits, { startHour, endHour, cutoff })
2419
+ renderLatestMonthlyRiskSummary(commits, { startHour, endHour, cutoff })
2420
+
2421
+ // 点击事件:点击某个作者在某周期的点,打开侧栏显示该作者在该周期内的加班提交明细(用于查看具体提交与时间)
2422
+ chart.on('click', (p) => {
2423
+ try {
2424
+ if (!p || p.componentType !== 'series') return
2425
+ const label = p.axisValue || p.name
2426
+ const author = p.seriesName
2427
+ if (!label || !author) return
2428
+ const type = document.querySelector('#tabsLatestOvertime button.active')?.dataset.type || 'daily'
2429
+
2430
+ const filteredCommits = commits.filter((c) => {
2431
+ const a = c.author || 'unknown'
2432
+ if (a !== author) return false
2433
+ const d = new Date(c.date)
2434
+ if (Number.isNaN(d.valueOf())) return false
2435
+ const h = d.getHours()
2436
+ let overtime = null
2437
+ if (h >= endHour && h < 24) overtime = h - endHour
2438
+ else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
2439
+ if (overtime == null) return false
2440
+
2441
+ if (type === 'daily') return d.toISOString().slice(0, 10) === label
2442
+ if (type === 'weekly') {
2443
+ if (!label.includes('-W')) return false
2444
+ const [yy, ww] = label.split('-W')
2445
+ const range = getISOWeekRange(Number(yy), Number(ww))
2446
+ const day = d.toISOString().slice(0, 10)
2447
+ return day >= range.start && day <= range.end
2448
+ }
2449
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2450
+ return month === label
2451
+ })
2452
+
2453
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
2454
+
2455
+ if (type === 'weekly') {
2456
+ const weeklyItem = { outsideWorkCount: filteredCommits.length, outsideWorkRate: 0 }
2457
+ showSideBarForWeek({ period: label, weeklyItem, commits: filteredCommits, titleDrawer: `${author} 本周最晚加班详情` })
2458
+ } else {
2459
+ showDayDetailSidebar({ date: label, count: filteredCommits.length, commits: filteredCommits, titleDrawer: `${author} 本日最晚加班详情` })
2460
+ }
2461
+ } catch (err) {
2462
+ console.warn('Latest overtime chart click handler error', err)
2463
+ }
2464
+ })
2465
+
2466
+ return chart
2467
+ }
2468
+
2469
+ // 本周“最晚加班”风险提示
2470
+ function renderLatestRiskSummary(
2471
+ commits,
2472
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2473
+ ) {
2474
+ const box = document.getElementById('latestRiskSummary')
2475
+ if (!box) return
2476
+
2477
+ const now = new Date()
2478
+ const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
2479
+ const prev = new Date(now)
2480
+ prev.setDate(prev.getDate() - 7)
2481
+ const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
2482
+
2483
+ // 统计每周每人最大超时
2484
+ const weekMax = new Map() // week -> Map(author -> {max, date})
2485
+ commits.forEach((c) => {
2486
+ const d = new Date(c.date)
2487
+ if (Number.isNaN(d.valueOf())) return
2488
+ const h = d.getHours()
2489
+ let overtime = null
2490
+ if (h >= endHour && h < 24) overtime = h - endHour
2491
+ else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
2492
+ if (overtime == null) return
2493
+
2494
+ const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
2495
+ if (!wKey) return
2496
+ if (!weekMax.has(wKey)) weekMax.set(wKey, new Map())
2497
+ const m = weekMax.get(wKey)
2498
+ const author = c.author || 'unknown'
2499
+ const cur = m.get(author)
2500
+ if (!cur || overtime > cur.max) {
2501
+ m.set(author, { max: overtime, date: d.toISOString().slice(0, 10) })
2502
+ }
2503
+ })
2504
+
2505
+ const curMap = weekMax.get(curKey) || new Map()
2506
+ const prevMap = weekMax.get(prevKey) || new Map()
2507
+
2508
+ // 当前周的全局最晚
2509
+ let topAuthor = null
2510
+ let top = { max: -1, date: null }
2511
+ curMap.forEach((v, k) => {
2512
+ if (v.max > top.max) {
2513
+ top = v
2514
+ topAuthor = k
2515
+ }
2516
+ })
2517
+
2518
+ // 上周全局最晚,用于趋势判断
2519
+ let prevMax = -1
2520
+ prevMap.forEach((v) => {
2521
+ if (v.max > prevMax) prevMax = v.max
2522
+ })
2523
+
2524
+ const lines = []
2525
+ lines.push('【本周最晚加班风险】')
2526
+
2527
+ if (top.max < 0) {
2528
+ lines.push('本周尚无下班后/凌晨提交,未发现明显风险。')
2529
+ } else {
2530
+ let trend = '暂无上周对比'
2531
+ if (prevMax >= 0) {
2532
+ if (top.max > prevMax) trend = '较上周更晚'
2533
+ else if (top.max < prevMax) trend = '较上周提前'
2534
+ else trend = '与上周持平'
2535
+ }
2536
+ lines.push(
2537
+ `${topAuthor} 本周最晚超出下班 ${top.max.toFixed(
2538
+ 2
2539
+ )} 小时(${top.date}),${trend}。`
2540
+ )
2541
+ if (top.max >= 2) {
2542
+ lines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
2543
+ } else if (top.max >= 1) {
2544
+ lines.push('已超过 1 小时,注意控制夜间工作时长。')
2545
+ }
2546
+ }
2547
+
2548
+ box.innerHTML = `
2549
+ <div class="risk-summary">
2550
+ <div class="risk-title">【本周最晚加班风险】</div>
2551
+ <ul>
2552
+ ${lines
2553
+ .slice(1)
2554
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2555
+ .join('')}
2556
+ </ul>
2557
+ </div>
2558
+ `
2559
+ }
2560
+
2561
+ function renderLatestMonthlyRiskSummary(
2562
+ commits,
2563
+ { startHour = 9, endHour = 18, cutoff = 6 } = {}
2564
+ ) {
2565
+ const box = document.getElementById('latestMonthlyRiskSummary')
2566
+ if (!box) return
2567
+
2568
+ const now = new Date()
2569
+ const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
2570
+ const prev = new Date(now)
2571
+ prev.setMonth(prev.getMonth() - 1)
2572
+ const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
2573
+
2574
+ const monthMax = new Map()
2575
+ commits.forEach((c) => {
2576
+ const d = new Date(c.date)
2577
+ if (Number.isNaN(d.valueOf())) return
2578
+ const h = d.getHours()
2579
+ let overtime = null
2580
+ if (h >= endHour && h < 24) overtime = h - endHour
2581
+ else if (h >= 0 && h < cutoff && h < startHour) overtime = 24 - endHour + h
2582
+ if (overtime == null) return
2583
+
2584
+ const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2585
+ if (!monthMax.has(mKey)) monthMax.set(mKey, new Map())
2586
+ const m = monthMax.get(mKey)
2587
+ const author = c.author || 'unknown'
2588
+ const cur = m.get(author)
2589
+ if (!cur || overtime > cur.max) {
2590
+ m.set(author, { max: overtime, date: d.toISOString().slice(0, 10) })
2591
+ }
2592
+ })
2593
+
2594
+ const curMap = monthMax.get(curKey) || new Map()
2595
+ const prevMap = monthMax.get(prevKey) || new Map()
2596
+
2597
+ let topAuthor = null
2598
+ let top = { max: -1, date: null }
2599
+ curMap.forEach((v, k) => {
2600
+ if (v.max > top.max) {
2601
+ top = v
2602
+ topAuthor = k
2603
+ }
2604
+ })
2605
+
2606
+ let prevMax = -1
2607
+ prevMap.forEach((v) => {
2608
+ if (v.max > prevMax) prevMax = v.max
2609
+ })
2610
+
2611
+ const lines = []
2612
+ lines.push('【本月最晚加班风险】')
2613
+
2614
+ if (top.max < 0) {
2615
+ lines.push('本月尚无下班后/凌晨提交,未发现明显风险。')
2616
+ } else {
2617
+ let trend = '暂无上月对比'
2618
+ if (prevMax >= 0) {
2619
+ if (top.max > prevMax) trend = '较上月更晚'
2620
+ else if (top.max < prevMax) trend = '较上月提前'
2621
+ else trend = '与上月持平'
2622
+ }
2623
+ lines.push(
2624
+ `${topAuthor} 本月最晚超出下班 ${top.max.toFixed(2)} 小时(${top.date}),${trend}。`
2625
+ )
2626
+ if (top.max >= 2) {
2627
+ lines.push('已超过 2 小时,存在严重加班风险,请关注工作节奏。')
2628
+ } else if (top.max >= 1) {
2629
+ lines.push('已超过 1 小时,注意控制夜间工作时长。')
2630
+ }
2631
+ }
2632
+
2633
+ box.innerHTML = `
2634
+ <div class="risk-summary">
2635
+ <div class="risk-title">【本月最晚加班风险】</div>
2636
+ <ul>
2637
+ ${lines
2638
+ .slice(1)
2639
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2640
+ .join('')}
2641
+ </ul>
2642
+ </div>
2643
+ `
2644
+ }
2645
+
2646
+ // ========= 开发者 午休最晚提交(小时) =========
2647
+ function buildAuthorLunchDataset(commits, type, lunchStart = 12, lunchEnd = 14) {
2648
+ const byAuthor = new Map()
2649
+ const periods = new Set()
2650
+
2651
+ commits.forEach((c) => {
2652
+ const d = new Date(c.date)
2653
+ if (Number.isNaN(d.valueOf())) return
2654
+ const h = d.getHours()
2655
+ const m = d.getMinutes()
2656
+ // 只考虑午休时间段内的提交
2657
+ if (!(h >= lunchStart && h < lunchEnd)) return
2658
+
2659
+ let key
2660
+ if (type === 'daily') key = d.toISOString().slice(0, 10)
2661
+ else if (type === 'weekly') key = getIsoWeekKey(d.toISOString().slice(0, 10))
2662
+ else key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2663
+ if (!key) return
2664
+ periods.add(key)
2665
+
2666
+ const author = c.author || 'unknown'
2667
+ if (!byAuthor.has(author)) byAuthor.set(author, {})
2668
+ const obj = byAuthor.get(author)
2669
+ // 以小时小数表示提交时间(例如 12.5 表示 12:30),后者越大表示越靠近午休结束
2670
+ const hourDecimal = h + m / 60
2671
+ obj[key] = Math.max(obj[key] || 0, hourDecimal)
2672
+ })
2673
+
2674
+ const allPeriods = Array.from(periods).sort()
2675
+ const authors = Array.from(byAuthor.keys()).sort()
2676
+ const series = authors.map((a) => ({
2677
+ name: a,
2678
+ type: 'line',
2679
+ smooth: true,
2680
+ data: allPeriods.map((p) => byAuthor.get(a)[p] || 0)
2681
+ }))
2682
+ return { authors, allPeriods, series }
2683
+ }
2684
+
2685
+ function formatHourDecimal(h) {
2686
+ if (h == null || h === 0) return '-'
2687
+ const hh = Math.floor(h)
2688
+ const mm = Math.round((h - hh) * 60)
2689
+ return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`
2690
+ }
2691
+
2692
+ function drawAuthorLunchTrends(commits, stats) {
2693
+ const el = document.getElementById('chartAuthorLunch')
2694
+ if (!el) return null
2695
+ const chart = echarts.init(el)
2696
+
2697
+ const lunchStart = typeof stats.lunchStart === 'number' ? stats.lunchStart : (window.__lunchStart ?? 12)
2698
+ const lunchEnd = typeof stats.lunchEnd === 'number' ? stats.lunchEnd : (window.__lunchEnd ?? 14)
2699
+
2700
+ function render(type) {
2701
+ const ds = buildAuthorLunchDataset(commits, type, lunchStart, lunchEnd)
2702
+ ds.rangeMap = {}
2703
+ for (const period of ds.allPeriods) {
2704
+ if (period.includes('-W')) {
2705
+ const [yy, ww] = period.split('-W')
2706
+ ds.rangeMap[period] = getISOWeekRange(Number(yy), Number(ww))
2707
+ }
2708
+ }
2709
+
2710
+ chart.setOption({
2711
+ tooltip: {
2712
+ trigger: 'axis',
2713
+ formatter(params) {
2714
+ if (!params || !params.length) return ''
2715
+ const label = params[0].axisValue
2716
+ const isWeekly = type === 'weekly'
2717
+
2718
+ let extra = ''
2719
+ if (isWeekly && ds.rangeMap && ds.rangeMap[label]) {
2720
+ const { start, end } = ds.rangeMap[label]
2721
+ extra = `<div style="margin-top:4px;color:#999;font-size:12px">周区间:${start} ~ ${end}</div>`
2722
+ }
2723
+
2724
+ const lines = params
2725
+ .filter((i) => i.data > 0)
2726
+ .sort((a, b) => (b.data || 0) - (a.data || 0) || String(a.seriesName).localeCompare(String(b.seriesName)))
2727
+ .map((item) => `${item.marker}${item.seriesName}: ${formatHourDecimal(item.data)}`)
2728
+ .join('<br/>')
2729
+
2730
+ return `<div>${label}</div>${extra}${lines}`
2731
+ }
2732
+ },
2733
+ legend: { data: ds.authors },
2734
+ xAxis: { type: 'category', data: ds.allPeriods },
2735
+ yAxis: { type: 'value', name: '时间(小时)', min: lunchStart, max: lunchEnd },
2736
+ series: ds.series
2737
+ })
2738
+ }
2739
+
2740
+ render('daily')
2741
+
2742
+ const tabs = document.querySelectorAll('#tabsLunch button')
2743
+ tabs.forEach((btnEl) => {
2744
+ btnEl.addEventListener('click', () => {
2745
+ tabs.forEach((b) => b.classList.remove('active'))
2746
+ btnEl.classList.add('active')
2747
+ render(btnEl.dataset.type)
2748
+ })
2749
+ })
2750
+
2751
+ renderLunchWeeklyRankSummary(commits, { lunchStart, lunchEnd })
2752
+ renderLunchWeeklyRiskSummary(commits, { lunchStart, lunchEnd })
2753
+ renderLunchMonthlyRankSummary(commits, { lunchStart, lunchEnd })
2754
+ renderLunchMonthlyRiskSummary(commits, { lunchStart, lunchEnd })
2755
+
2756
+ // 点击事件:点击某个数据点(作者+周期)打开侧栏,展示该作者在该周期午休时间段内的提交明细
2757
+ chart.on('click', (p) => {
2758
+ try {
2759
+ if (!p || p.componentType !== 'series') return
2760
+ const label = p.axisValue || p.name
2761
+ const author = p.seriesName
2762
+ if (!label || !author) return
2763
+
2764
+ // 识别当前 tabs 类型(daily|weekly|monthly)
2765
+ const type = document.querySelector('#tabsLunch button.active')?.dataset.type || 'daily'
2766
+
2767
+ // 过滤 commits:作者匹配 + 在午休时间段内 + 在所选周期内
2768
+ const filteredCommits = commits.filter((c) => {
2769
+ const a = c.author || 'unknown'
2770
+ if (a !== author) return false
2771
+ const d = new Date(c.date)
2772
+ if (Number.isNaN(d.valueOf())) return false
2773
+ const h = d.getHours()
2774
+ const m = d.getMinutes()
2775
+ if (!(h >= lunchStart && h < lunchEnd)) return false
2776
+
2777
+ if (type === 'daily') {
2778
+ return d.toISOString().slice(0, 10) === label
2779
+ }
2780
+ if (type === 'weekly') {
2781
+ // label 格式 YYYY-Www
2782
+ if (!label.includes('-W')) return false
2783
+ const [yy, ww] = label.split('-W')
2784
+ const range = getISOWeekRange(Number(yy), Number(ww))
2785
+ const day = d.toISOString().slice(0, 10)
2786
+ return day >= range.start && day <= range.end
2787
+ }
2788
+ // monthly
2789
+ const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2790
+ return month === label
2791
+ })
2792
+
2793
+ filteredCommits.sort((a, b) => new Date(a.date) - new Date(b.date))
2794
+
2795
+ if (type === 'weekly') {
2796
+ const weeklyItem = { outsideWorkCount: filteredCommits.length, outsideWorkRate: 0 }
2797
+ showSideBarForWeek({ period: label, weeklyItem, commits: filteredCommits, titleDrawer: `${author} 午休本周提交详情` })
2798
+ } else {
2799
+ showDayDetailSidebar({ date: label, count: filteredCommits.length, commits: filteredCommits, titleDrawer: `${author} 午休 ${type} 提交` })
2800
+ }
2801
+ } catch (err) {
2802
+ console.warn('Lunch chart click handler error', err)
2803
+ }
2804
+ })
2805
+
2806
+ return chart
2807
+ }
2808
+
2809
+ function renderLunchWeeklyRankSummary(commits, { lunchStart = 12, lunchEnd = 14 } = {}) {
2810
+ const box = document.getElementById('lunchWeeklyRankSummary')
2811
+ if (!box) return
2812
+
2813
+ const now = new Date()
2814
+ const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
2815
+
2816
+ const weekDays = new Map() // week -> Map(author -> Set(dates))
2817
+ commits.forEach((c) => {
2818
+ const d = new Date(c.date)
2819
+ if (Number.isNaN(d.valueOf())) return
2820
+ const h = d.getHours()
2821
+ const m = d.getMinutes()
2822
+ if (!(h >= lunchStart && h < lunchEnd)) return
2823
+ const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
2824
+ if (wKey !== curKey) return
2825
+ const author = c.author || 'unknown'
2826
+ if (!weekDays.has(author)) weekDays.set(author, new Set())
2827
+ weekDays.get(author).add(d.toISOString().slice(0, 10))
2828
+ })
2829
+
2830
+ const weeklyRanks = []
2831
+ weekDays.forEach((set, author) => {
2832
+ weeklyRanks.push({ author, days: set.size })
2833
+ })
2834
+ weeklyRanks.sort((a, b) => b.days - a.days || String(a.author).localeCompare(String(b.author)))
2835
+
2836
+ const lines = []
2837
+ lines.push('【本周午休清醒者排行榜】')
2838
+ if (weeklyRanks.length === 0) {
2839
+ lines.push('本周无人午休提交,暂无清醒者排行榜。')
2840
+ } else {
2841
+ weeklyRanks.forEach((r, idx) => {
2842
+ const rank = idx + 1
2843
+ const medal = rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
2844
+ const title = rank === 1 ? '(状元・昼魔侠)' : ''
2845
+ lines.push(`${rank}. ${medal}${r.author} — ${r.days} 天${title}`)
2846
+ })
2847
+ }
2848
+
2849
+ box.innerHTML = `
2850
+ <div class="risk-summary">
2851
+ <div class="risk-title">【本周午休清醒者排行榜】</div>
2852
+ <ul>
2853
+ ${lines
2854
+ .slice(1)
2855
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2856
+ .join('')}
2857
+ </ul>
2858
+ </div>
2859
+ `
2860
+ }
2861
+
2862
+ function renderLunchWeeklyRiskSummary(commits, { lunchStart = 12, lunchEnd = 14 } = {}) {
2863
+ const box = document.getElementById('lunchWeeklyRiskSummary')
2864
+ if (!box) return
2865
+
2866
+ const now = new Date()
2867
+ const curKey = getIsoWeekKey(now.toISOString().slice(0, 10))
2868
+ const prev = new Date(now)
2869
+ prev.setDate(prev.getDate() - 7)
2870
+ const prevKey = getIsoWeekKey(prev.toISOString().slice(0, 10))
2871
+
2872
+ const weekMax = new Map() // week -> Map(author -> {val, date, time})
2873
+ commits.forEach((c) => {
2874
+ const d = new Date(c.date)
2875
+ if (Number.isNaN(d.valueOf())) return
2876
+ const h = d.getHours()
2877
+ const m = d.getMinutes()
2878
+ if (!(h >= lunchStart && h < lunchEnd)) return
2879
+ const wKey = getIsoWeekKey(d.toISOString().slice(0, 10))
2880
+ if (!wKey) return
2881
+
2882
+ if (!weekMax.has(wKey)) weekMax.set(wKey, new Map())
2883
+ const mMap = weekMax.get(wKey)
2884
+ const author = c.author || 'unknown'
2885
+ const val = h + m / 60
2886
+ const cur = mMap.get(author)
2887
+ if (!cur || val > cur.val) mMap.set(author, { val, date: d.toISOString().slice(0, 10), time: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` })
2888
+ })
2889
+
2890
+ const curMap = weekMax.get(curKey) || new Map()
2891
+ const prevMap = weekMax.get(prevKey) || new Map()
2892
+
2893
+ let topAuthor = null
2894
+ let top = { val: -1, date: null, time: null }
2895
+ curMap.forEach((v, k) => {
2896
+ if (v.val > top.val) {
2897
+ top = v
2898
+ topAuthor = k
2899
+ }
2900
+ })
2901
+
2902
+ let prevMax = -1
2903
+ prevMap.forEach((v) => {
2904
+ if (v.val > prevMax) prevMax = v.val
2905
+ })
2906
+
2907
+ const lines = []
2908
+ lines.push('【本周午休最晚提交风险】')
2909
+
2910
+ if (top.val < 0) {
2911
+ lines.push('本周午休期间暂无提交记录。')
2912
+ } else {
2913
+ let trend = '暂无上周对比'
2914
+ if (prevMax >= 0) {
2915
+ if (top.val > prevMax) trend = '较上周更晚'
2916
+ else if (top.val < prevMax) trend = '较上周提前'
2917
+ else trend = '与上周持平'
2918
+ }
2919
+ lines.push(`${topAuthor} 本周午休最晚提交:${top.time}(${top.date}),${trend}。)`)
2920
+ if (top.val >= lunchEnd - 0.5) lines.push('存在午间延迟提交风险,请关注短时间内频繁占用午休。')
2921
+ }
2922
+
2923
+ box.innerHTML = `
2924
+ <div class="risk-summary">
2925
+ <div class="risk-title">【本周午休最晚提交风险】</div>
2926
+ <ul>
2927
+ ${lines
2928
+ .slice(1)
2929
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2930
+ .join('')}
2931
+ </ul>
2932
+ </div>
2933
+ `
2934
+ }
2935
+
2936
+ function renderLunchMonthlyRankSummary(commits, { lunchStart = 12, lunchEnd = 14 } = {}) {
2937
+ const box = document.getElementById('lunchMonthlyRankSummary')
2938
+ if (!box) return
2939
+
2940
+ const now = new Date()
2941
+ const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
2942
+
2943
+ const monthDays = new Map() // author -> Set(dates)
2944
+ commits.forEach((c) => {
2945
+ const d = new Date(c.date)
2946
+ if (Number.isNaN(d.valueOf())) return
2947
+ const h = d.getHours()
2948
+ const m = d.getMinutes()
2949
+ if (!(h >= lunchStart && h < lunchEnd)) return
2950
+ const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
2951
+ if (mKey !== curKey) return
2952
+ const author = c.author || 'unknown'
2953
+ if (!monthDays.has(author)) monthDays.set(author, new Set())
2954
+ monthDays.get(author).add(d.toISOString().slice(0, 10))
2955
+ })
2956
+
2957
+ const monthlyRanks = []
2958
+ monthDays.forEach((set, author) => {
2959
+ monthlyRanks.push({ author, days: set.size })
2960
+ })
2961
+ monthlyRanks.sort((a, b) => b.days - a.days || String(a.author).localeCompare(String(b.author)))
2962
+
2963
+ const lines = []
2964
+ lines.push('【本月午休清醒者排行榜】')
2965
+ if (monthlyRanks.length === 0) {
2966
+ lines.push('本月无人午休提交,暂无清醒者排行榜。')
2967
+ } else {
2968
+ monthlyRanks.forEach((r, idx) => {
2969
+ const rank = idx + 1
2970
+ const medal = rank === 1 ? '🥇 ' : rank === 2 ? '🥈 ' : rank === 3 ? '🥉 ' : ''
2971
+ const title = rank === 1 ? '(状元・昼魔侠)' : ''
2972
+ lines.push(`${rank}. ${medal}${r.author} — ${r.days} 天${title}`)
2973
+ })
2974
+ }
2975
+
2976
+ box.innerHTML = `
2977
+ <div class="risk-summary">
2978
+ <div class="risk-title">【本月午休清醒者排行榜】</div>
2979
+ <ul>
2980
+ ${lines
2981
+ .slice(1)
2982
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
2983
+ .join('')}
2984
+ </ul>
2985
+ </div>
2986
+ `
2987
+ }
2988
+
2989
+ function renderLunchMonthlyRiskSummary(commits, { lunchStart = 12, lunchEnd = 14 } = {}) {
2990
+ const box = document.getElementById('lunchMonthlyRiskSummary')
2991
+ if (!box) return
2992
+
2993
+ const now = new Date()
2994
+ const curKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
2995
+ const prev = new Date(now)
2996
+ prev.setMonth(prev.getMonth() - 1)
2997
+ const prevKey = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
2998
+
2999
+ const monthMax = new Map()
3000
+ commits.forEach((c) => {
3001
+ const d = new Date(c.date)
3002
+ if (Number.isNaN(d.valueOf())) return
3003
+ const h = d.getHours()
3004
+ const m = d.getMinutes()
3005
+ if (!(h >= lunchStart && h < lunchEnd)) return
3006
+ const mKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
3007
+ if (!monthMax.has(mKey)) monthMax.set(mKey, new Map())
3008
+ const mm = monthMax.get(mKey)
3009
+ const author = c.author || 'unknown'
3010
+ const val = h + m / 60
3011
+ const cur = mm.get(author)
3012
+ if (!cur || val > cur.val) mm.set(author, { val, date: d.toISOString().slice(0, 10), time: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` })
3013
+ })
3014
+
3015
+ const curMap = monthMax.get(curKey) || new Map()
3016
+ const prevMap = monthMax.get(prevKey) || new Map()
3017
+
3018
+ let topAuthor = null
3019
+ let top = { val: -1, date: null }
3020
+ curMap.forEach((v, k) => {
3021
+ if (v.val > top.val) {
3022
+ top = v
3023
+ topAuthor = k
3024
+ }
3025
+ })
3026
+
3027
+ let prevMax = -1
3028
+ prevMap.forEach((v) => {
3029
+ if (v.val > prevMax) prevMax = v.val
3030
+ })
3031
+
3032
+ const lines = []
3033
+ lines.push('【本月午休最晚提交风险】')
3034
+
3035
+ if (top.val < 0) {
3036
+ lines.push('本月午休期间暂无提交记录。')
3037
+ } else {
3038
+ let trend = '暂无上月对比'
3039
+ if (prevMax >= 0) {
3040
+ if (top.val > prevMax) trend = '较上月更晚'
3041
+ else if (top.val < prevMax) trend = '较上月提前'
3042
+ else trend = '与上月持平'
3043
+ }
3044
+ lines.push(`${topAuthor} 本月午休最晚提交:${top.time}(${top.date}),${trend}。)`)
3045
+ if (top.val >= lunchEnd - 0.5) lines.push('存在午间延迟提交风险,请关注短时间内频繁占用午休。')
3046
+ }
3047
+
3048
+ box.innerHTML = `
3049
+ <div class="risk-summary">
3050
+ <div class="risk-title">【本月午休最晚提交风险】</div>
3051
+ <ul>
3052
+ ${lines
3053
+ .slice(1)
3054
+ .map((l) => `<li>${escapeHtml(l)}</li>`)
3055
+ .join('')}
3056
+ </ul>
3057
+ </div>
3058
+ `
3059
+ }
3060
+
3061
+ async function main() {
3062
+ const {
3063
+ commits,
3064
+ stats,
3065
+ weekly,
3066
+ monthly,
3067
+ latestByDay,
3068
+ config,
3069
+ authorChanges
3070
+ } = await loadData()
3071
+ commitsAll = commits
3072
+ filtered = commitsAll.slice()
3073
+ window.__overtimeEndHour =
3074
+ stats && typeof stats.endHour === 'number'
3075
+ ? stats.endHour
3076
+ : (config.endHour ?? 18)
3077
+ window.__overnightCutoff =
3078
+ typeof config.overnightCutoff === 'number' ? config.overnightCutoff : 6
3079
+ // lunch config(用于午休图表)
3080
+ window.__lunchStart = stats && typeof stats.lunchStart === 'number' ? stats.lunchStart : (config.lunchStart ?? 12)
3081
+ window.__lunchEnd = stats && typeof stats.lunchEnd === 'number' ? stats.lunchEnd : (config.lunchEnd ?? 14)
3082
+ initTableControls()
3083
+ updatePager()
3084
+ renderCommitsTablePage()
3085
+
3086
+ drawHourlyOvertime(stats, (hour) => {
3087
+ // 使用举例
3088
+ const hourCommitsDetail = groupCommitsByHour(commits)
3089
+ // 将 commit 列表传给侧栏(若没有详情,则传空数组)
3090
+ showSideBarForHour({
3091
+ hour,
3092
+ commitsOrCount: hourCommitsDetail[hour] || [],
3093
+ titleDrawer: '每小时加班分布'
3094
+ })
3095
+ })
3096
+ drawOutsideVsInside(stats)
3097
+
3098
+ // 按日提交趋势:点击某天打开抽屉,显示当日所有 commits
3099
+ drawDailyTrend(commits, showDayDetailSidebar)
3100
+
3101
+ // 周趋势:保持原有点击行为(显示该周详情)
3102
+ drawWeeklyTrend(weekly, commits, showSideBarForWeek)
3103
+
3104
+ // 月趋势(加班占比):点击某个月打开抽屉,显示该月所有 commits
3105
+ drawMonthlyTrend(monthly, commits, showDayDetailSidebar)
3106
+
3107
+ // 每日最晚提交时间(小时):点击某天打开抽屉,显示当日所有 commits
3108
+ drawLatestHourDaily(latestByDay, commits, showDayDetailSidebar)
3109
+
3110
+ // 每日超过下班的小时数:点击某天打开抽屉,显示当日所有 commits
3111
+ drawDailySeverity(latestByDay, commits, showDayDetailSidebar)
3112
+
3113
+ const daily = drawDailyTrendSeverity(commits, weekly, showDayDetailSidebar)
3114
+
3115
+ console.log('最累的一天:', daily.analysis.mostTiredDay)
3116
+
3117
+ drawChangeTrends(authorChanges)
3118
+ drawAuthorOvertimeTrends(commits, stats)
3119
+ drawAuthorLatestOvertimeTrends(commits, stats)
3120
+ drawAuthorLunchTrends(commits, stats)
3121
+ computeAndRenderLatestOvertime(latestByDay)
3122
+ renderKpi(stats)
3123
+ }
3124
+
3125
+ // 抽屉关闭交互(按钮 + 点击遮罩)
3126
+ document.getElementById('sidebarClose').onclick = () => {
3127
+ document.getElementById('dayDetailSidebar').classList.remove('show')
3128
+ const backdrop = document.getElementById('sidebarBackdrop')
3129
+ if (backdrop) backdrop.classList.remove('show')
3130
+ }
3131
+
3132
+ const sidebarBackdropEl = document.getElementById('sidebarBackdrop')
3133
+ if (sidebarBackdropEl) {
3134
+ sidebarBackdropEl.addEventListener('click', () => {
3135
+ document.getElementById('dayDetailSidebar').classList.remove('show')
3136
+ sidebarBackdropEl.classList.remove('show')
3137
+ })
3138
+ }
3139
+ main()