wukong-gitlog-cli 1.0.14 → 1.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +4 -0
- package/README.zh-CN.md +4 -0
- package/package.json +1 -1
- package/src/cli.mjs +107 -19
- package/web/app.js +392 -82
- package/web/index.html +6 -12
- package/web/static/style.css +122 -22
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [1.0.16](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.15...v1.0.16) (2025-12-03)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* 🎸 sider bar UI ([e3a48ab](https://github.com/tomatobybike/wukong-gitlog-cli/commit/e3a48abe5b0148fe0d9dcd42b7b028cad53d4c94))
|
|
11
|
+
* 🎸 ui show ([e9eba58](https://github.com/tomatobybike/wukong-gitlog-cli/commit/e9eba5823333545d598be363b58eca1f6ecfa6ed))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* 🐛 makeline clickEvent ([4ad6378](https://github.com/tomatobybike/wukong-gitlog-cli/commit/4ad6378d88aca2dde6083efde844730443228e48))
|
|
17
|
+
|
|
18
|
+
### [1.0.15](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.14...v1.0.15) (2025-12-03)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Features
|
|
22
|
+
|
|
23
|
+
* 🎸 dayDetailSidebar ([21b99fd](https://github.com/tomatobybike/wukong-gitlog-cli/commit/21b99fd859af45641987dfd5f50a8c5604a380c5))
|
|
24
|
+
* 🎸 each day ([ffa3d5e](https://github.com/tomatobybike/wukong-gitlog-cli/commit/ffa3d5e6192a69a727c747ffa325b56ae5bf78d4))
|
|
25
|
+
* 🎸 hourlyOvertimeChart ([93f62b7](https://github.com/tomatobybike/wukong-gitlog-cli/commit/93f62b7b9906154a16bdcf38bd7b0979d44bebfe))
|
|
26
|
+
* 🎸 latestOvertimeDay ([40cb157](https://github.com/tomatobybike/wukong-gitlog-cli/commit/40cb15788411441306e0c838108817fa36268624))
|
|
27
|
+
* 🎸 next day cutof ([efe6a5f](https://github.com/tomatobybike/wukong-gitlog-cli/commit/efe6a5f41e54ca0fdadb3c0071f1fcf7a286e6c2))
|
|
28
|
+
* 🎸 onDayClick ([9132b54](https://github.com/tomatobybike/wukong-gitlog-cli/commit/9132b5423449ceb8a964d813dfd4931834993913))
|
|
29
|
+
* 🎸 renderKpi ([e26cc0d](https://github.com/tomatobybike/wukong-gitlog-cli/commit/e26cc0deac83a7e9bccaae81ba572da0e39e82b3))
|
|
30
|
+
* 🎸 week range ([cd565da](https://github.com/tomatobybike/wukong-gitlog-cli/commit/cd565dac98b6c38442eeacdd6dd423b93d657ab0))
|
|
31
|
+
|
|
5
32
|
### [1.0.14](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.13...v1.0.14) (2025-12-02)
|
|
6
33
|
|
|
7
34
|
|
package/README.md
CHANGED
package/README.zh-CN.md
CHANGED
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk'
|
|
2
2
|
import { Command } from 'commander'
|
|
3
3
|
import dayjs from 'dayjs'
|
|
4
|
+
import isoWeek from 'dayjs/plugin/isoWeek.js'
|
|
4
5
|
import fs from 'fs'
|
|
5
6
|
import path from 'path'
|
|
6
7
|
import { fileURLToPath } from 'url'
|
|
@@ -31,6 +32,8 @@ const pkg = JSON.parse(
|
|
|
31
32
|
fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')
|
|
32
33
|
)
|
|
33
34
|
|
|
35
|
+
dayjs.extend(isoWeek)
|
|
36
|
+
|
|
34
37
|
const PKG_NAME = pkg.name
|
|
35
38
|
const VERSION = pkg.version
|
|
36
39
|
|
|
@@ -51,6 +54,21 @@ const version = async () => {
|
|
|
51
54
|
process.exit(0)
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
/** 将 "2025-W48" → { start: '2025-11-24', end: '2025-11-30' } */
|
|
58
|
+
export function getWeekRange(periodStr) {
|
|
59
|
+
// periodStr = "2025-W48"
|
|
60
|
+
const [year, w] = periodStr.split('-W')
|
|
61
|
+
const week = parseInt(w, 10)
|
|
62
|
+
|
|
63
|
+
const start = dayjs().year(year).isoWeek(week).startOf('week') // Monday
|
|
64
|
+
const end = dayjs().year(year).isoWeek(week).endOf('week') // Sunday
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
start: start.format('YYYY-MM-DD'),
|
|
68
|
+
end: end.format('YYYY-MM-DD')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
54
72
|
const main = async () => {
|
|
55
73
|
const program = new Command()
|
|
56
74
|
|
|
@@ -343,6 +361,7 @@ const main = async () => {
|
|
|
343
361
|
})
|
|
344
362
|
return {
|
|
345
363
|
period: k,
|
|
364
|
+
range: getWeekRange(k),
|
|
346
365
|
total: s.total,
|
|
347
366
|
outsideWorkCount: s.outsideWorkCount,
|
|
348
367
|
outsideWorkRate: s.outsideWorkRate,
|
|
@@ -380,7 +399,10 @@ const main = async () => {
|
|
|
380
399
|
nonWorkdayRate: s.nonWorkdayRate
|
|
381
400
|
}
|
|
382
401
|
})
|
|
383
|
-
const dataMonthlyFile = outputFilePath(
|
|
402
|
+
const dataMonthlyFile = outputFilePath(
|
|
403
|
+
'data/overtime-monthly.mjs',
|
|
404
|
+
outDir
|
|
405
|
+
)
|
|
384
406
|
const monthlyModule = `export default ${JSON.stringify(monthlySeries, null, 2)};\n`
|
|
385
407
|
writeTextFile(dataMonthlyFile, monthlyModule)
|
|
386
408
|
console.log(chalk.green(`Monthly series 已导出: ${dataMonthlyFile}`))
|
|
@@ -388,29 +410,92 @@ const main = async () => {
|
|
|
388
410
|
// 新增:每日最晚提交小时(用于显著展示加班严重程度)
|
|
389
411
|
const dayGroups2 = groupRecords(records, 'day')
|
|
390
412
|
const dayKeys2 = Object.keys(dayGroups2).sort()
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
413
|
+
|
|
414
|
+
// 次日凌晨归并窗口(默认 6 点前仍算前一天的加班)
|
|
415
|
+
const overnightCutoff = Number.isFinite(opts.overnightCutoff)
|
|
416
|
+
? opts.overnightCutoff
|
|
417
|
+
: 6
|
|
418
|
+
// 次日上班时间(默认按 workStart,若未指定则 9 点)
|
|
419
|
+
const workStartHour =
|
|
420
|
+
opts.workStart || opts.workStart === 0 ? opts.workStart : 9
|
|
421
|
+
const workEndHour =
|
|
422
|
+
opts.workEnd || opts.workEnd === 0 ? opts.workEnd : 18
|
|
423
|
+
|
|
424
|
+
// 有些日期「本身没有 commit」,但第二天凌晨有提交要归并到这一天,
|
|
425
|
+
// 需要补出这些“虚拟日期”,否则 latestByDay 会漏掉这天。
|
|
426
|
+
const virtualPrevDays = new Set()
|
|
427
|
+
records.forEach((r) => {
|
|
428
|
+
const d = new Date(r.date)
|
|
429
|
+
if (Number.isNaN(d.valueOf())) return
|
|
430
|
+
const h = d.getHours()
|
|
431
|
+
if (h < 0 || h >= overnightCutoff || h >= workStartHour) return
|
|
432
|
+
const curDay = dayjs(d).format('YYYY-MM-DD')
|
|
433
|
+
const prevDay = dayjs(curDay).subtract(1, 'day').format('YYYY-MM-DD')
|
|
434
|
+
if (!dayGroups2[prevDay]) {
|
|
435
|
+
virtualPrevDays.add(prevDay)
|
|
436
|
+
}
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
const allDayKeys = Array.from(
|
|
440
|
+
new Set([...dayKeys2, ...virtualPrevDays])
|
|
441
|
+
).sort()
|
|
442
|
+
|
|
443
|
+
const latestByDay = allDayKeys.map((k) => {
|
|
444
|
+
const list = dayGroups2[k] || []
|
|
445
|
+
|
|
446
|
+
// 1) 当天「下班后」的提交:只统计 >= workEndHour 的小时
|
|
447
|
+
const sameDayHours = list
|
|
448
|
+
.map((r) => new Date(r.date))
|
|
449
|
+
.filter((d) => !Number.isNaN(d.valueOf()))
|
|
450
|
+
.map((d) => d.getHours())
|
|
451
|
+
.filter((h) => h >= workEndHour && h < 24)
|
|
452
|
+
|
|
453
|
+
// 2) 次日凌晨、但仍算前一日加班的提交
|
|
400
454
|
const nextKey = dayjs(k).add(1, 'day').format('YYYY-MM-DD')
|
|
401
455
|
const early = dayGroups2[nextKey] || []
|
|
402
456
|
const earlyHours = early
|
|
403
457
|
.map((r) => new Date(r.date))
|
|
404
458
|
.filter((d) => !Number.isNaN(d.valueOf()))
|
|
405
459
|
.map((d) => d.getHours())
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
460
|
+
// 只看 [0, overnightCutoff) 之间的小时,
|
|
461
|
+
// 并且默认认为 < workStartHour 属于「次日上班前」
|
|
462
|
+
.filter(
|
|
463
|
+
(h) =>
|
|
464
|
+
h >= 0 &&
|
|
465
|
+
h < overnightCutoff &&
|
|
466
|
+
// 保护性判断:若有人把 overnightCutoff 设得大于上班时间,
|
|
467
|
+
// 我们仍然只统计到上班时间为止
|
|
468
|
+
h < workStartHour
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
// 3) 计算「逻辑上的最晚加班时间」
|
|
472
|
+
// - 当天晚上的用原始小时(如 22 点)
|
|
473
|
+
// - 次日凌晨的用 24 + 小时(如 1 点 → 25)
|
|
474
|
+
const overtimeValues = [
|
|
475
|
+
...sameDayHours.map((h) => h),
|
|
476
|
+
...earlyHours.map((h) => 24 + h)
|
|
477
|
+
]
|
|
478
|
+
|
|
479
|
+
if (overtimeValues.length === 0) {
|
|
480
|
+
// 这一天没有任何「下班后到次日上班前」的提交
|
|
481
|
+
return {
|
|
482
|
+
date: k,
|
|
483
|
+
latestHour: null,
|
|
484
|
+
latestHourNormalized: null
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const latestHourNormalized = Math.max(...overtimeValues)
|
|
489
|
+
|
|
490
|
+
// latestHour 保留「当天自然日内」的最晚提交通常小时数,供前端需要时参考
|
|
491
|
+
const sameDayMax =
|
|
492
|
+
sameDayHours.length > 0 ? Math.max(...sameDayHours) : null
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
date: k,
|
|
496
|
+
latestHour: sameDayMax,
|
|
497
|
+
latestHourNormalized
|
|
498
|
+
}
|
|
414
499
|
})
|
|
415
500
|
const dataLatestByDayFile = outputFilePath(
|
|
416
501
|
'data/overtime-latest-by-day.mjs',
|
|
@@ -432,7 +517,10 @@ const main = async () => {
|
|
|
432
517
|
lunchEnd: opts.lunchEnd || 14,
|
|
433
518
|
overnightCutoff
|
|
434
519
|
}
|
|
435
|
-
writeTextFile(
|
|
520
|
+
writeTextFile(
|
|
521
|
+
configFile,
|
|
522
|
+
`export default ${JSON.stringify(cfg, null, 2)};\n`
|
|
523
|
+
)
|
|
436
524
|
console.log(chalk.green(`Config 已导出: ${configFile}`))
|
|
437
525
|
} catch (e) {
|
|
438
526
|
console.warn('Export config failed:', e && e.message ? e.message : e)
|
package/web/app.js
CHANGED
|
@@ -129,7 +129,7 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
129
129
|
trigger: 'axis',
|
|
130
130
|
formatter(params) {
|
|
131
131
|
const p = params[0]
|
|
132
|
-
const h = parseInt(p.axisValue,10)
|
|
132
|
+
const h = parseInt(p.axisValue, 10)
|
|
133
133
|
const count = p.value
|
|
134
134
|
const rate = (percent[h] * 100).toFixed(1)
|
|
135
135
|
return `
|
|
@@ -184,14 +184,24 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
184
184
|
data: [
|
|
185
185
|
{
|
|
186
186
|
name: '上班开始',
|
|
187
|
+
nameValue: String(stats.startHour).padStart(2, '0'),
|
|
187
188
|
xAxis: String(stats.startHour).padStart(2, '0')
|
|
188
189
|
},
|
|
189
|
-
{
|
|
190
|
+
{
|
|
191
|
+
name: '下班时间',
|
|
192
|
+
nameValue: String(stats.endHour).padStart(2, '0'),
|
|
193
|
+
xAxis: String(stats.endHour).padStart(2, '0')
|
|
194
|
+
},
|
|
190
195
|
{
|
|
191
196
|
name: '午休开始',
|
|
197
|
+
nameValue: String(stats.lunchStart).padStart(2, '0'),
|
|
192
198
|
xAxis: String(stats.lunchStart).padStart(2, '0')
|
|
193
199
|
},
|
|
194
|
-
{
|
|
200
|
+
{
|
|
201
|
+
name: '午休结束',
|
|
202
|
+
nameValue: String(stats.lunchEnd).padStart(2, '0'),
|
|
203
|
+
xAxis: String(stats.lunchEnd).padStart(2, '0')
|
|
204
|
+
}
|
|
195
205
|
]
|
|
196
206
|
}
|
|
197
207
|
}
|
|
@@ -201,7 +211,14 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
201
211
|
// 点击事件(点击某小时 → 打开侧栏)
|
|
202
212
|
if (typeof onHourClick === 'function') {
|
|
203
213
|
chart.on('click', (p) => {
|
|
204
|
-
|
|
214
|
+
let hour = Number(p.name)
|
|
215
|
+
if(p.componentType === 'markLine') {
|
|
216
|
+
hour = Number(p.data.xAxis)
|
|
217
|
+
}
|
|
218
|
+
// FIXME: remove debug log before production
|
|
219
|
+
console.log('❌', 'hour', hour, p)
|
|
220
|
+
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
221
|
+
if (Object.is(hour, NaN)) return
|
|
205
222
|
onHourClick(hour, commits[hour])
|
|
206
223
|
})
|
|
207
224
|
}
|
|
@@ -212,9 +229,11 @@ function drawHourlyOvertime(stats, onHourClick) {
|
|
|
212
229
|
// showSideBarForHour 实现
|
|
213
230
|
function showSideBarForHour(hour, commitsOrCount) {
|
|
214
231
|
// 支持传入 number(仅次数)或 array(详细 commit 列表)
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
const
|
|
232
|
+
// 统一复用通用详情侧栏 DOM
|
|
233
|
+
const sidebar = document.getElementById('dayDetailSidebar')
|
|
234
|
+
const backdrop = document.getElementById('sidebarBackdrop')
|
|
235
|
+
const titleEl = document.getElementById('sidebarTitle')
|
|
236
|
+
const contentEl = document.getElementById('sidebarContent')
|
|
218
237
|
|
|
219
238
|
// 兼容未传入侧栏 DOM 的情况(优雅降级)
|
|
220
239
|
if (!sidebar || !titleEl || !contentEl) {
|
|
@@ -234,19 +253,22 @@ function showSideBarForHour(hour, commitsOrCount) {
|
|
|
234
253
|
} else if (Array.isArray(commitsOrCount)) {
|
|
235
254
|
// commits 列表:展示作者/时间/消息(最多前 50 条,避免性能问题)
|
|
236
255
|
const commits = commitsOrCount.slice(0, 50)
|
|
237
|
-
contentEl.innerHTML = commits
|
|
256
|
+
contentEl.innerHTML = `<div class="sidebar-list">${commits
|
|
238
257
|
.map((c) => {
|
|
239
258
|
const author = c.author ?? c.name ?? 'unknown'
|
|
240
259
|
const time = c.date ?? c.time ?? ''
|
|
241
260
|
const msg = (c.message ?? c.msg ?? c.body ?? '').replace(/\n/g, ' ')
|
|
242
261
|
return `
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
262
|
+
<div class="sidebar-item">
|
|
263
|
+
<div class="sidebar-item-header">
|
|
264
|
+
<span class="author">👤 ${escapeHtml(author)}</span>
|
|
265
|
+
<span class="time">🕒 ${escapeHtml(time)}</span>
|
|
266
|
+
</div>
|
|
267
|
+
<div class="sidebar-item-message">${escapeHtml(msg)}</div>
|
|
268
|
+
</div>
|
|
269
|
+
`
|
|
248
270
|
})
|
|
249
|
-
.join('')
|
|
271
|
+
.join('')}</div>`
|
|
250
272
|
|
|
251
273
|
if (commitsOrCount.length > 50) {
|
|
252
274
|
const more = commitsOrCount.length - 50
|
|
@@ -256,18 +278,11 @@ function showSideBarForHour(hour, commitsOrCount) {
|
|
|
256
278
|
contentEl.innerHTML = `<div style="font-size:14px;">无可展示数据</div>`
|
|
257
279
|
}
|
|
258
280
|
|
|
259
|
-
// 打开侧栏
|
|
281
|
+
// 打开侧栏 + 遮罩
|
|
260
282
|
sidebar.classList.add('show')
|
|
283
|
+
if (backdrop) backdrop.classList.add('show')
|
|
261
284
|
}
|
|
262
285
|
|
|
263
|
-
// 关闭按钮绑定(只需运行一次)
|
|
264
|
-
;(function bindHourSidebarClose() {
|
|
265
|
-
const btn = document.getElementById('hourSidebarClose')
|
|
266
|
-
const sidebar = document.getElementById('hourDetailSidebar')
|
|
267
|
-
if (!btn || !sidebar) return
|
|
268
|
-
btn.addEventListener('click', () => sidebar.classList.remove('show'))
|
|
269
|
-
})()
|
|
270
|
-
|
|
271
286
|
// 简单的 HTML 转义,防止 XSS 与布局断裂
|
|
272
287
|
function escapeHtml(str = '') {
|
|
273
288
|
return String(str)
|
|
@@ -301,7 +316,7 @@ function drawOutsideVsInside(stats) {
|
|
|
301
316
|
return chart
|
|
302
317
|
}
|
|
303
318
|
|
|
304
|
-
function drawDailyTrend(commits) {
|
|
319
|
+
function drawDailyTrend(commits, onDayClick) {
|
|
305
320
|
if (!Array.isArray(commits) || commits.length === 0) return null
|
|
306
321
|
|
|
307
322
|
// 聚合每日提交数量
|
|
@@ -398,10 +413,66 @@ function drawDailyTrend(commits) {
|
|
|
398
413
|
]
|
|
399
414
|
})
|
|
400
415
|
|
|
416
|
+
// 点击某一天,打开抽屉显示当日 commits
|
|
417
|
+
if (typeof onDayClick === 'function') {
|
|
418
|
+
chart.on('click', (params) => {
|
|
419
|
+
const idx = params.dataIndex
|
|
420
|
+
const date = labels[idx]
|
|
421
|
+
const count = data[idx]
|
|
422
|
+
const dayCommits = commits.filter(
|
|
423
|
+
(c) => new Date(c.date).toISOString().slice(0, 10) === date
|
|
424
|
+
)
|
|
425
|
+
onDayClick(date, count, dayCommits)
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
401
429
|
return chart
|
|
402
430
|
}
|
|
403
431
|
|
|
404
|
-
function
|
|
432
|
+
function showSideBarForWeek(period, weeklyItem, commits = []) {
|
|
433
|
+
// 统一复用通用详情侧栏 DOM
|
|
434
|
+
const sidebar = document.getElementById('dayDetailSidebar')
|
|
435
|
+
const backdrop = document.getElementById('sidebarBackdrop')
|
|
436
|
+
const titleEl = document.getElementById('sidebarTitle')
|
|
437
|
+
const contentEl = document.getElementById('sidebarContent')
|
|
438
|
+
|
|
439
|
+
titleEl.innerHTML = `📅 周期:<b>${period}</b>`
|
|
440
|
+
|
|
441
|
+
let html = `
|
|
442
|
+
<div style="padding:6px 0;">
|
|
443
|
+
加班次数:<b>${weeklyItem.outsideWorkCount}</b><br/>
|
|
444
|
+
占比:<b>${(weeklyItem.outsideWorkRate * 100).toFixed(1)}%</b>
|
|
445
|
+
</div>
|
|
446
|
+
<hr/>
|
|
447
|
+
`
|
|
448
|
+
|
|
449
|
+
if (!commits.length) {
|
|
450
|
+
html += `<div style="padding:10px;color:#777;">该周无提交记录</div>`
|
|
451
|
+
} else {
|
|
452
|
+
html += `<div class="sidebar-list">${commits
|
|
453
|
+
.map((c) => {
|
|
454
|
+
const author = escapeHtml(c.author || 'unknown')
|
|
455
|
+
const time = escapeHtml(c.date || '')
|
|
456
|
+
const msg = escapeHtml((c.message || '').replace(/\n/g, ' '))
|
|
457
|
+
return `
|
|
458
|
+
<div class="sidebar-item">
|
|
459
|
+
<div class="sidebar-item-header">
|
|
460
|
+
<span class="author">👤 ${author}</span>
|
|
461
|
+
<span class="time">🕒 ${time}</span>
|
|
462
|
+
</div>
|
|
463
|
+
<div class="sidebar-item-message">${msg}</div>
|
|
464
|
+
</div>
|
|
465
|
+
`
|
|
466
|
+
})
|
|
467
|
+
.join('')}</div>`
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
contentEl.innerHTML = html
|
|
471
|
+
sidebar.classList.add('show')
|
|
472
|
+
if (backdrop) backdrop.classList.add('show')
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function drawWeeklyTrend(weekly, commits, onWeekClick) {
|
|
405
476
|
if (!Array.isArray(weekly) || weekly.length === 0) return null
|
|
406
477
|
|
|
407
478
|
const labels = weekly.map((w) => w.period)
|
|
@@ -409,13 +480,16 @@ function drawWeeklyTrend(weekly) {
|
|
|
409
480
|
const dataCount = weekly.map((w) => w.outsideWorkCount)
|
|
410
481
|
|
|
411
482
|
const el = document.getElementById('weeklyTrendChart')
|
|
412
|
-
// eslint-disable-next-line no-undef
|
|
413
483
|
const chart = echarts.init(el)
|
|
414
484
|
|
|
415
485
|
chart.setOption({
|
|
416
486
|
tooltip: {
|
|
417
487
|
trigger: 'axis',
|
|
418
488
|
formatter: (params) => {
|
|
489
|
+
const pp = params[0]
|
|
490
|
+
const weekItem = weekly[pp.dataIndex]
|
|
491
|
+
const { start, end } = weekItem.range
|
|
492
|
+
|
|
419
493
|
const rate = params.find((p) => p.seriesName.includes('%'))?.data
|
|
420
494
|
const count = params.find((p) => p.seriesName.includes('次数'))?.data
|
|
421
495
|
|
|
@@ -425,22 +499,20 @@ function drawWeeklyTrend(weekly) {
|
|
|
425
499
|
if (rate >= 20) level = '🔴 严重(≥20%)'
|
|
426
500
|
|
|
427
501
|
return `
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
502
|
+
<div style="font-size:13px; line-height:1.5;">
|
|
503
|
+
<b>${params[0].axisValue}</b><br/>
|
|
504
|
+
📅 周区间:<b>${start} ~ ${end}</b><br/>
|
|
505
|
+
加班占比:<b>${rate}%</b><br/>
|
|
506
|
+
加班次数:${count} 次<br/>
|
|
507
|
+
等级:${level}
|
|
508
|
+
</div>
|
|
509
|
+
`
|
|
435
510
|
}
|
|
436
511
|
},
|
|
437
512
|
|
|
438
|
-
legend: {
|
|
439
|
-
top: 10
|
|
440
|
-
},
|
|
513
|
+
legend: { top: 10 },
|
|
441
514
|
|
|
442
515
|
xAxis: { type: 'category', data: labels },
|
|
443
|
-
|
|
444
516
|
yAxis: [
|
|
445
517
|
{ type: 'value', min: 0, max: 100, name: '占比(%)' },
|
|
446
518
|
{ type: 'value', name: '次数', min: 0 }
|
|
@@ -451,26 +523,22 @@ function drawWeeklyTrend(weekly) {
|
|
|
451
523
|
type: 'line',
|
|
452
524
|
name: '加班占比(%)',
|
|
453
525
|
data: dataRate,
|
|
454
|
-
|
|
455
|
-
// ⭐ 区间背景(与 monthly/daily 对齐)
|
|
456
526
|
markArea: {
|
|
457
527
|
data: [
|
|
458
528
|
[
|
|
459
529
|
{ yAxis: 0 },
|
|
460
|
-
{ yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } }
|
|
530
|
+
{ yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } }
|
|
461
531
|
],
|
|
462
532
|
[
|
|
463
533
|
{ yAxis: 10 },
|
|
464
|
-
{ yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } }
|
|
534
|
+
{ yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } }
|
|
465
535
|
],
|
|
466
536
|
[
|
|
467
537
|
{ yAxis: 20 },
|
|
468
|
-
{ yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } }
|
|
538
|
+
{ yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } }
|
|
469
539
|
]
|
|
470
540
|
]
|
|
471
541
|
},
|
|
472
|
-
|
|
473
|
-
// ⭐ 阈值线
|
|
474
542
|
markLine: {
|
|
475
543
|
symbol: ['none', 'arrow'],
|
|
476
544
|
data: [
|
|
@@ -493,17 +561,35 @@ function drawWeeklyTrend(weekly) {
|
|
|
493
561
|
name: '加班次数',
|
|
494
562
|
data: dataCount,
|
|
495
563
|
yAxisIndex: 1,
|
|
496
|
-
|
|
497
|
-
// 次数线使用默认蓝色,避免干扰等级颜色区间
|
|
498
564
|
smooth: true
|
|
499
565
|
}
|
|
500
566
|
]
|
|
501
567
|
})
|
|
502
568
|
|
|
569
|
+
// ⭐ 点击事件:从 commits 过滤该周提交
|
|
570
|
+
chart.on('click', (p) => {
|
|
571
|
+
const idx = p.dataIndex
|
|
572
|
+
const w = weekly[idx]
|
|
573
|
+
|
|
574
|
+
const start = new Date(w.range.start)
|
|
575
|
+
const end = new Date(w.range.end)
|
|
576
|
+
end.setHours(23, 59, 59, 999) // 包含当天
|
|
577
|
+
|
|
578
|
+
const weeklyCommits = commits.filter((c) => {
|
|
579
|
+
const d = new Date(c.date)
|
|
580
|
+
return d >= start && d <= end
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
// 回调交给外面决定如何打开侧栏
|
|
584
|
+
if (typeof onWeekClick === 'function') {
|
|
585
|
+
onWeekClick(w.period, w, weeklyCommits)
|
|
586
|
+
}
|
|
587
|
+
})
|
|
588
|
+
|
|
503
589
|
return chart
|
|
504
590
|
}
|
|
505
591
|
|
|
506
|
-
function drawMonthlyTrend(monthly) {
|
|
592
|
+
function drawMonthlyTrend(monthly, commits, onMonthClick) {
|
|
507
593
|
if (!Array.isArray(monthly) || monthly.length === 0) return null
|
|
508
594
|
|
|
509
595
|
const labels = monthly.map((m) => m.period)
|
|
@@ -599,10 +685,27 @@ function drawMonthlyTrend(monthly) {
|
|
|
599
685
|
]
|
|
600
686
|
})
|
|
601
687
|
|
|
688
|
+
// 点击某个月份,打开抽屉显示该月的所有 commits
|
|
689
|
+
if (typeof onMonthClick === 'function' && Array.isArray(commits)) {
|
|
690
|
+
chart.on('click', (params) => {
|
|
691
|
+
const idx = params.dataIndex
|
|
692
|
+
const ym = labels[idx] // 'YYYY-MM'
|
|
693
|
+
const monthCommits = commits.filter((c) => {
|
|
694
|
+
const d = new Date(c.date)
|
|
695
|
+
const m = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(
|
|
696
|
+
2,
|
|
697
|
+
'0'
|
|
698
|
+
)}`
|
|
699
|
+
return m === ym
|
|
700
|
+
})
|
|
701
|
+
onMonthClick(ym, monthCommits.length, monthCommits)
|
|
702
|
+
})
|
|
703
|
+
}
|
|
704
|
+
|
|
602
705
|
return chart
|
|
603
706
|
}
|
|
604
707
|
|
|
605
|
-
function drawLatestHourDaily(latestByDay) {
|
|
708
|
+
function drawLatestHourDaily(latestByDay, commits, onDayClick) {
|
|
606
709
|
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
|
|
607
710
|
|
|
608
711
|
const labels = latestByDay.map((d) => d.date)
|
|
@@ -680,6 +783,8 @@ function drawLatestHourDaily(latestByDay) {
|
|
|
680
783
|
type: 'line',
|
|
681
784
|
name: '每日最晚提交小时',
|
|
682
785
|
data,
|
|
786
|
+
// 让折线在 null 点之间连起来,避免视觉上“断裂”
|
|
787
|
+
connectNulls: true,
|
|
683
788
|
markLine: {
|
|
684
789
|
symbol: ['none', 'arrow'],
|
|
685
790
|
data: [
|
|
@@ -715,10 +820,28 @@ function drawLatestHourDaily(latestByDay) {
|
|
|
715
820
|
]
|
|
716
821
|
})
|
|
717
822
|
|
|
823
|
+
// 点击某一天的最晚提交时间点,打开抽屉显示该日 commits
|
|
824
|
+
if (typeof onDayClick === 'function' && Array.isArray(commits)) {
|
|
825
|
+
// 预聚合:按天收集 commits
|
|
826
|
+
const dayCommitsMap = {}
|
|
827
|
+
commits.forEach((c) => {
|
|
828
|
+
const d = new Date(c.date).toISOString().slice(0, 10)
|
|
829
|
+
if (!dayCommitsMap[d]) dayCommitsMap[d] = []
|
|
830
|
+
dayCommitsMap[d].push(c)
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
chart.on('click', (params) => {
|
|
834
|
+
const idx = params.dataIndex
|
|
835
|
+
const date = labels[idx]
|
|
836
|
+
const list = dayCommitsMap[date] || []
|
|
837
|
+
onDayClick(date, list.length, list)
|
|
838
|
+
})
|
|
839
|
+
}
|
|
840
|
+
|
|
718
841
|
return chart
|
|
719
842
|
}
|
|
720
843
|
|
|
721
|
-
function drawDailySeverity(latestByDay) {
|
|
844
|
+
function drawDailySeverity(latestByDay, commits, onDayClick) {
|
|
722
845
|
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
|
|
723
846
|
|
|
724
847
|
const labels = latestByDay.map((d) => d.date)
|
|
@@ -730,7 +853,9 @@ function drawDailySeverity(latestByDay) {
|
|
|
730
853
|
: (d.latestHour ?? null)
|
|
731
854
|
)
|
|
732
855
|
|
|
733
|
-
|
|
856
|
+
// 若某天 latestHourNormalized 为空,表示「没有下班后到次日上班前的提交」,
|
|
857
|
+
// 这里按 0 小时加班处理,保证折线连续。
|
|
858
|
+
const sev = raw.map((v) => (v == null ? 0 : Math.max(0, Number(v) - endH)))
|
|
734
859
|
|
|
735
860
|
const el = document.getElementById('dailySeverityChart')
|
|
736
861
|
// eslint-disable-next-line no-undef
|
|
@@ -746,15 +871,6 @@ function drawDailySeverity(latestByDay) {
|
|
|
746
871
|
const overtime = p.data
|
|
747
872
|
const rawHour = raw[p.dataIndex] // 原始 latestHour 或 latestHourNormalized
|
|
748
873
|
|
|
749
|
-
if (overtime == null) {
|
|
750
|
-
return `
|
|
751
|
-
<div style="font-size:13px;">
|
|
752
|
-
<b>${date}</b><br/>
|
|
753
|
-
无数据
|
|
754
|
-
</div>
|
|
755
|
-
`
|
|
756
|
-
}
|
|
757
|
-
|
|
758
874
|
return `
|
|
759
875
|
<div style="font-size:13px;">
|
|
760
876
|
<b>${date}</b><br/>
|
|
@@ -781,6 +897,8 @@ function drawDailySeverity(latestByDay) {
|
|
|
781
897
|
type: 'line',
|
|
782
898
|
name: '超过下班小时数',
|
|
783
899
|
data: sev,
|
|
900
|
+
// 连续显示 0 小时加班的日期,避免折线断开
|
|
901
|
+
connectNulls: true,
|
|
784
902
|
|
|
785
903
|
// ⭐ 加班区域背景
|
|
786
904
|
markArea: {
|
|
@@ -828,6 +946,23 @@ function drawDailySeverity(latestByDay) {
|
|
|
828
946
|
]
|
|
829
947
|
})
|
|
830
948
|
|
|
949
|
+
// 点击某一天的「超过下班小时数」点,打开抽屉显示该日 commits
|
|
950
|
+
if (typeof onDayClick === 'function' && Array.isArray(commits)) {
|
|
951
|
+
const dayCommitsMap = {}
|
|
952
|
+
commits.forEach((c) => {
|
|
953
|
+
const d = new Date(c.date).toISOString().slice(0, 10)
|
|
954
|
+
if (!dayCommitsMap[d]) dayCommitsMap[d] = []
|
|
955
|
+
dayCommitsMap[d].push(c)
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
chart.on('click', (params) => {
|
|
959
|
+
const idx = params.dataIndex
|
|
960
|
+
const date = labels[idx]
|
|
961
|
+
const list = dayCommitsMap[date] || []
|
|
962
|
+
onDayClick(date, list.length, list)
|
|
963
|
+
})
|
|
964
|
+
}
|
|
965
|
+
|
|
831
966
|
return chart
|
|
832
967
|
}
|
|
833
968
|
|
|
@@ -991,6 +1126,7 @@ function drawDailyTrendSeverity(commits, weekly, onDayClick) {
|
|
|
991
1126
|
|
|
992
1127
|
function showDayDetailSidebar(date, count, commits) {
|
|
993
1128
|
const sidebar = document.getElementById('dayDetailSidebar')
|
|
1129
|
+
const backdrop = document.getElementById('sidebarBackdrop')
|
|
994
1130
|
const title = document.getElementById('sidebarTitle')
|
|
995
1131
|
const content = document.getElementById('sidebarContent')
|
|
996
1132
|
|
|
@@ -1000,22 +1136,19 @@ function showDayDetailSidebar(date, count, commits) {
|
|
|
1000
1136
|
content.innerHTML = commits
|
|
1001
1137
|
.map(
|
|
1002
1138
|
(c) => `
|
|
1003
|
-
<div
|
|
1004
|
-
<div
|
|
1005
|
-
|
|
1006
|
-
|
|
1139
|
+
<div class="sidebar-item">
|
|
1140
|
+
<div class="sidebar-item-header">
|
|
1141
|
+
<span class="author">👤 ${escapeHtml(c.author || 'unknown')}</span>
|
|
1142
|
+
<span class="time">🕒 ${escapeHtml(c.time || c.date || '')}</span>
|
|
1143
|
+
</div>
|
|
1144
|
+
<div class="sidebar-item-message">${escapeHtml(c.msg || c.message || '')}</div>
|
|
1007
1145
|
</div>
|
|
1008
|
-
<hr/>
|
|
1009
1146
|
`
|
|
1010
1147
|
)
|
|
1011
1148
|
.join('')
|
|
1012
1149
|
|
|
1013
1150
|
sidebar.classList.add('show')
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
// 关闭按钮
|
|
1017
|
-
document.getElementById('sidebarClose').onclick = () => {
|
|
1018
|
-
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
1151
|
+
if (backdrop) backdrop.classList.add('show')
|
|
1019
1152
|
}
|
|
1020
1153
|
|
|
1021
1154
|
function renderKpi(stats) {
|
|
@@ -1023,11 +1156,54 @@ function renderKpi(stats) {
|
|
|
1023
1156
|
if (!el || !stats) return
|
|
1024
1157
|
const latest = stats.latestCommit
|
|
1025
1158
|
const latestHour = stats.latestCommitHour
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
stats.latestOutsideCommitHour ??
|
|
1029
|
-
(latestOut ? new Date(latestOut.date).getHours() : null)
|
|
1159
|
+
|
|
1160
|
+
// 使用 cutoff + 上下班时间,重新在全部 commits 中计算「加班最晚一次提交」
|
|
1030
1161
|
const cutoff = window.__overnightCutoff ?? 6
|
|
1162
|
+
const startHour =
|
|
1163
|
+
typeof stats.startHour === 'number' && stats.startHour >= 0
|
|
1164
|
+
? stats.startHour
|
|
1165
|
+
: 9
|
|
1166
|
+
const endHour =
|
|
1167
|
+
typeof stats.endHour === 'number' && stats.endHour >= 0
|
|
1168
|
+
? stats.endHour
|
|
1169
|
+
: (window.__overtimeEndHour ?? 18)
|
|
1170
|
+
|
|
1171
|
+
let latestOut = null
|
|
1172
|
+
let latestOutHour = null
|
|
1173
|
+
let maxSeverity = -1
|
|
1174
|
+
|
|
1175
|
+
if (Array.isArray(commitsAll) && commitsAll.length > 0) {
|
|
1176
|
+
commitsAll.forEach((c) => {
|
|
1177
|
+
const d = new Date(c.date)
|
|
1178
|
+
if (!d || Number.isNaN(d.valueOf())) return
|
|
1179
|
+
const h = d.getHours()
|
|
1180
|
+
|
|
1181
|
+
// 只看「当日下班后」以及「次日凌晨 cutoff 之前,且仍在上班前」的提交
|
|
1182
|
+
let sev = null
|
|
1183
|
+
if (h >= endHour && h < 24) {
|
|
1184
|
+
// 当晚:直接按 h - endHour 计算
|
|
1185
|
+
sev = h - endHour
|
|
1186
|
+
} else if (h >= 0 && h < cutoff && h < startHour) {
|
|
1187
|
+
// 次日凌晨:视作跨天,加上 24
|
|
1188
|
+
sev = 24 - endHour + h
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (sev != null && sev >= 0 && sev > maxSeverity) {
|
|
1192
|
+
maxSeverity = sev
|
|
1193
|
+
latestOut = c
|
|
1194
|
+
latestOutHour = h
|
|
1195
|
+
}
|
|
1196
|
+
})
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// 若按 cutoff 没算出结果,则退回到原来的 stats.latestOutsideCommit
|
|
1200
|
+
if (!latestOut && stats.latestOutsideCommit) {
|
|
1201
|
+
latestOut = stats.latestOutsideCommit
|
|
1202
|
+
latestOutHour =
|
|
1203
|
+
stats.latestOutsideCommitHour ??
|
|
1204
|
+
(latestOut ? new Date(latestOut.date).getHours() : null)
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1031
1207
|
const html = [
|
|
1032
1208
|
`<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>`,
|
|
1033
1209
|
`<div class="hr"></div>`,
|
|
@@ -1050,7 +1226,7 @@ function groupCommitsByHour(commits) {
|
|
|
1050
1226
|
return byHour
|
|
1051
1227
|
}
|
|
1052
1228
|
|
|
1053
|
-
|
|
1229
|
+
async function main() {
|
|
1054
1230
|
const { commits, stats, weekly, monthly, latestByDay, config } =
|
|
1055
1231
|
await loadData()
|
|
1056
1232
|
commitsAll = commits
|
|
@@ -1064,21 +1240,155 @@ function groupCommitsByHour(commits) {
|
|
|
1064
1240
|
initTableControls()
|
|
1065
1241
|
updatePager()
|
|
1066
1242
|
renderCommitsTablePage()
|
|
1067
|
-
// 使用举例
|
|
1068
|
-
const hourCommitsDetail = groupCommitsByHour(commits)
|
|
1069
1243
|
|
|
1070
1244
|
drawHourlyOvertime(stats, (hour, count) => {
|
|
1245
|
+
// 使用举例
|
|
1246
|
+
const hourCommitsDetail = groupCommitsByHour(commits)
|
|
1071
1247
|
// 将 commit 列表传给侧栏(若没有详情,则传空数组)
|
|
1072
1248
|
showSideBarForHour(hour, hourCommitsDetail[hour] || [])
|
|
1073
1249
|
})
|
|
1074
1250
|
drawOutsideVsInside(stats)
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1251
|
+
|
|
1252
|
+
// 按日提交趋势:点击某天打开抽屉,显示当日所有 commits
|
|
1253
|
+
drawDailyTrend(commits, showDayDetailSidebar)
|
|
1254
|
+
|
|
1255
|
+
// 周趋势:保持原有点击行为(显示该周详情)
|
|
1256
|
+
drawWeeklyTrend(weekly, commits, showSideBarForWeek)
|
|
1257
|
+
|
|
1258
|
+
// 月趋势(加班占比):点击某个月打开抽屉,显示该月所有 commits
|
|
1259
|
+
drawMonthlyTrend(monthly, commits, showDayDetailSidebar)
|
|
1260
|
+
|
|
1261
|
+
// 每日最晚提交时间(小时):点击某天打开抽屉,显示当日所有 commits
|
|
1262
|
+
drawLatestHourDaily(latestByDay, commits, showDayDetailSidebar)
|
|
1263
|
+
|
|
1264
|
+
// 每日超过下班的小时数:点击某天打开抽屉,显示当日所有 commits
|
|
1265
|
+
drawDailySeverity(latestByDay, commits, showDayDetailSidebar)
|
|
1266
|
+
|
|
1080
1267
|
const daily = drawDailyTrendSeverity(commits, weekly, showDayDetailSidebar)
|
|
1081
1268
|
|
|
1082
1269
|
console.log('最累的一天:', daily.analysis.mostTiredDay)
|
|
1270
|
+
computeAndRenderLatestOvertime(latestByDay)
|
|
1083
1271
|
renderKpi(stats)
|
|
1084
|
-
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// 基于 latestByDay + cutoff/endHour 统计「最晚加班的一天 / 一周 / 一月」
|
|
1275
|
+
function computeAndRenderLatestOvertime(latestByDay) {
|
|
1276
|
+
if (!Array.isArray(latestByDay) || latestByDay.length === 0) return
|
|
1277
|
+
|
|
1278
|
+
const endH = window.__overtimeEndHour || 18
|
|
1279
|
+
|
|
1280
|
+
// 每天的 latestHourNormalized → 超出下班的小时数
|
|
1281
|
+
const dailyOvertime = latestByDay
|
|
1282
|
+
.map((d) => {
|
|
1283
|
+
const v =
|
|
1284
|
+
typeof d.latestHourNormalized === 'number'
|
|
1285
|
+
? d.latestHourNormalized
|
|
1286
|
+
: typeof d.latestHour === 'number'
|
|
1287
|
+
? d.latestHour
|
|
1288
|
+
: null
|
|
1289
|
+
if (v == null) return null
|
|
1290
|
+
const overtime = Math.max(0, Number(v) - endH)
|
|
1291
|
+
return { date: d.date, overtime, raw: v }
|
|
1292
|
+
})
|
|
1293
|
+
.filter(Boolean)
|
|
1294
|
+
|
|
1295
|
+
if (!dailyOvertime.length) return
|
|
1296
|
+
|
|
1297
|
+
// 1) 最晚加班的一天(超出下班小时数最大,若相同取日期更晚)
|
|
1298
|
+
const dailySorted = [...dailyOvertime].sort((a, b) => {
|
|
1299
|
+
if (b.overtime !== a.overtime) return b.overtime - a.overtime
|
|
1300
|
+
return new Date(b.date) - new Date(a.date)
|
|
1301
|
+
})
|
|
1302
|
+
const worstDay = dailySorted[0]
|
|
1303
|
+
const dayEl = document.getElementById('latestOvertimeDay')
|
|
1304
|
+
if (dayEl) {
|
|
1305
|
+
dayEl.innerHTML = `⏰ 最晚加班的一天:<b>${worstDay.date}</b>(超过下班 <b>${worstDay.overtime.toFixed(
|
|
1306
|
+
2
|
|
1307
|
+
)}</b> 小时,逻辑时间约 ${worstDay.raw.toFixed(2)} 点)`
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// 工具:根据日期字符串计算 ISO 周 key:YYYY-Www
|
|
1311
|
+
const getIsoWeekKey = (dStr) => {
|
|
1312
|
+
const d = new Date(dStr)
|
|
1313
|
+
if (Number.isNaN(d.valueOf())) return null
|
|
1314
|
+
const target = new Date(
|
|
1315
|
+
Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())
|
|
1316
|
+
)
|
|
1317
|
+
const dayNum = target.getUTCDay() || 7 // Sunday=0
|
|
1318
|
+
target.setUTCDate(target.getUTCDate() + 4 - dayNum)
|
|
1319
|
+
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
|
|
1320
|
+
const weekNo = Math.ceil(((target - yearStart) / 86400000 + 1) / 7)
|
|
1321
|
+
const year = target.getUTCFullYear()
|
|
1322
|
+
return `${year}-W${String(weekNo).padStart(2, '0')}`
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// 2) 按周聚合:每周取「该周内任意一天的最大加班时长」
|
|
1326
|
+
const weekMap = new Map()
|
|
1327
|
+
dailyOvertime.forEach((d) => {
|
|
1328
|
+
const key = getIsoWeekKey(d.date)
|
|
1329
|
+
if (!key) return
|
|
1330
|
+
const cur = weekMap.get(key)
|
|
1331
|
+
if (!cur || d.overtime > cur.overtime) {
|
|
1332
|
+
weekMap.set(key, d)
|
|
1333
|
+
}
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
if (weekMap.size) {
|
|
1337
|
+
const weeks = Array.from(weekMap.entries()).sort((a, b) => {
|
|
1338
|
+
if (b[1].overtime !== a[1].overtime) return b[1].overtime - a[1].overtime
|
|
1339
|
+
return new Date(b[1].date) - new Date(a[1].date)
|
|
1340
|
+
})
|
|
1341
|
+
const [weekKey, weekInfo] = weeks[0]
|
|
1342
|
+
const weekEl = document.getElementById('latestOvertimeWeek')
|
|
1343
|
+
if (weekEl) {
|
|
1344
|
+
weekEl.innerHTML = `⏰ 最晚加班的一周:<b>${weekKey}</b>(代表日期 ${weekInfo.date},超过下班 <b>${weekInfo.overtime.toFixed(
|
|
1345
|
+
2
|
|
1346
|
+
)}</b> 小时)`
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// 3) 按月聚合:每月取「该月任意一天的最大加班时长」
|
|
1351
|
+
const monthMap = new Map()
|
|
1352
|
+
dailyOvertime.forEach((d) => {
|
|
1353
|
+
const dt = new Date(d.date)
|
|
1354
|
+
if (Number.isNaN(dt.valueOf())) return
|
|
1355
|
+
const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(
|
|
1356
|
+
2,
|
|
1357
|
+
'0'
|
|
1358
|
+
)}`
|
|
1359
|
+
const cur = monthMap.get(key)
|
|
1360
|
+
if (!cur || d.overtime > cur.overtime) {
|
|
1361
|
+
monthMap.set(key, d)
|
|
1362
|
+
}
|
|
1363
|
+
})
|
|
1364
|
+
|
|
1365
|
+
if (monthMap.size) {
|
|
1366
|
+
const months = Array.from(monthMap.entries()).sort((a, b) => {
|
|
1367
|
+
if (b[1].overtime !== a[1].overtime) return b[1].overtime - a[1].overtime
|
|
1368
|
+
return new Date(b[1].date) - new Date(a[1].date)
|
|
1369
|
+
})
|
|
1370
|
+
const [monthKey, monthInfo] = months[0]
|
|
1371
|
+
const monthEl = document.getElementById('latestOvertimeMonth')
|
|
1372
|
+
if (monthEl) {
|
|
1373
|
+
monthEl.innerHTML = `⏰ 最晚加班的月份:<b>${monthKey}</b>(代表日期 ${monthInfo.date},超过下班 <b>${monthInfo.overtime.toFixed(
|
|
1374
|
+
2
|
|
1375
|
+
)}</b> 小时)`
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// 抽屉关闭交互(按钮 + 点击遮罩)
|
|
1381
|
+
document.getElementById('sidebarClose').onclick = () => {
|
|
1382
|
+
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
1383
|
+
const backdrop = document.getElementById('sidebarBackdrop')
|
|
1384
|
+
if (backdrop) backdrop.classList.remove('show')
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const sidebarBackdropEl = document.getElementById('sidebarBackdrop')
|
|
1388
|
+
if (sidebarBackdropEl) {
|
|
1389
|
+
sidebarBackdropEl.addEventListener('click', () => {
|
|
1390
|
+
document.getElementById('dayDetailSidebar').classList.remove('show')
|
|
1391
|
+
sidebarBackdropEl.classList.remove('show')
|
|
1392
|
+
})
|
|
1393
|
+
}
|
|
1394
|
+
main()
|
package/web/index.html
CHANGED
|
@@ -56,6 +56,9 @@
|
|
|
56
56
|
<div id="mostTiredDay"></div>
|
|
57
57
|
<div id="mostTiredWeek"></div>
|
|
58
58
|
<div id="mostTiredMonth"></div>
|
|
59
|
+
<div id="latestOvertimeDay"></div>
|
|
60
|
+
<div id="latestOvertimeWeek"></div>
|
|
61
|
+
<div id="latestOvertimeMonth"></div>
|
|
59
62
|
</section>
|
|
60
63
|
|
|
61
64
|
<section class="table-card">
|
|
@@ -98,26 +101,17 @@
|
|
|
98
101
|
<code>/data/</code>.</small
|
|
99
102
|
>
|
|
100
103
|
</footer>
|
|
101
|
-
<!--
|
|
104
|
+
<!-- 通用:右侧滑出的详情侧栏(小时 / 天 / 周 复用同一个 DOM) -->
|
|
105
|
+
<!-- 背景遮罩,点击可关闭侧栏 -->
|
|
106
|
+
<div id="sidebarBackdrop" class="sidebar-backdrop"></div>
|
|
102
107
|
<div id="dayDetailSidebar" class="sidebar">
|
|
103
108
|
<div class="sidebar-header">
|
|
104
109
|
<span id="sidebarTitle"></span>
|
|
105
110
|
<button id="sidebarClose">×</button>
|
|
106
111
|
</div>
|
|
107
|
-
|
|
108
112
|
<div id="sidebarContent" class="sidebar-content"></div>
|
|
109
113
|
</div>
|
|
110
114
|
|
|
111
|
-
<!-- 小时详情侧栏(独立) -->
|
|
112
|
-
<div id="hourDetailSidebar" class="sidebar">
|
|
113
|
-
<div class="sidebar-header">
|
|
114
|
-
<span id="hourSidebarTitle"> </span>
|
|
115
|
-
<button id="hourSidebarClose">×</button>
|
|
116
|
-
</div>
|
|
117
|
-
|
|
118
|
-
<div id="hourSidebarContent" class="sidebar-content"></div>
|
|
119
|
-
</div>
|
|
120
|
-
|
|
121
115
|
<script type="module" src="/app.js"></script>
|
|
122
116
|
</body>
|
|
123
117
|
</html>
|
package/web/static/style.css
CHANGED
|
@@ -189,20 +189,40 @@ td {
|
|
|
189
189
|
margin-bottom: 8px;
|
|
190
190
|
color: #00a76f;
|
|
191
191
|
}
|
|
192
|
+
/* 抽屉 & 遮罩(参考 Material UI Drawer) -------------------- */
|
|
193
|
+
.sidebar-backdrop {
|
|
194
|
+
position: fixed;
|
|
195
|
+
inset: 0;
|
|
196
|
+
background: rgba(15, 23, 42, 0.38); /* 深色半透明遮罩 */
|
|
197
|
+
opacity: 0;
|
|
198
|
+
pointer-events: none;
|
|
199
|
+
transition: opacity 0.25s ease;
|
|
200
|
+
z-index: 1200;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.sidebar-backdrop.show {
|
|
204
|
+
opacity: 1;
|
|
205
|
+
pointer-events: auto;
|
|
206
|
+
}
|
|
207
|
+
|
|
192
208
|
.sidebar {
|
|
193
209
|
position: fixed;
|
|
194
210
|
top: 0;
|
|
195
|
-
right: -
|
|
196
|
-
width:
|
|
211
|
+
right: -460px;
|
|
212
|
+
width: min(460px, 100%);
|
|
197
213
|
height: 100%;
|
|
198
214
|
background: #fff;
|
|
199
|
-
box-shadow:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
215
|
+
box-shadow:
|
|
216
|
+
0 10px 15px rgba(0, 0, 0, 0.1),
|
|
217
|
+
0 4px 6px rgba(0, 0, 0, 0.08);
|
|
218
|
+
transition: right 0.3s ease;
|
|
219
|
+
z-index: 1300;
|
|
220
|
+
padding: 20px 20px 16px;
|
|
203
221
|
display: flex;
|
|
204
222
|
flex-direction: column;
|
|
205
223
|
box-sizing: border-box;
|
|
224
|
+
border-radius: 12px 0 0 12px;
|
|
225
|
+
border-left: 1px solid rgba(145, 158, 171, 0.24);
|
|
206
226
|
}
|
|
207
227
|
|
|
208
228
|
.sidebar.show {
|
|
@@ -213,35 +233,115 @@ td {
|
|
|
213
233
|
display: flex;
|
|
214
234
|
justify-content: space-between;
|
|
215
235
|
align-items: center;
|
|
216
|
-
font-size:
|
|
217
|
-
|
|
236
|
+
font-size: 16px;
|
|
237
|
+
font-weight: 500;
|
|
238
|
+
margin-bottom: 12px;
|
|
239
|
+
color: #111827;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.sidebar-header span {
|
|
243
|
+
display: inline-flex;
|
|
244
|
+
align-items: center;
|
|
245
|
+
gap: 6px;
|
|
218
246
|
}
|
|
219
247
|
|
|
220
248
|
.sidebar-header button {
|
|
221
|
-
|
|
249
|
+
width: 32px;
|
|
250
|
+
height: 32px;
|
|
251
|
+
display: inline-flex;
|
|
252
|
+
align-items: center;
|
|
253
|
+
justify-content: center;
|
|
254
|
+
background: rgba(148, 163, 184, 0.12);
|
|
222
255
|
border: none;
|
|
223
|
-
|
|
256
|
+
border-radius: 999px;
|
|
257
|
+
font-size: 18px;
|
|
224
258
|
cursor: pointer;
|
|
259
|
+
color: #4b5563;
|
|
260
|
+
transition:
|
|
261
|
+
background 0.2s ease,
|
|
262
|
+
color 0.2s ease,
|
|
263
|
+
box-shadow 0.2s ease;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.sidebar-header button:hover {
|
|
267
|
+
background: rgba(148, 163, 184, 0.22);
|
|
268
|
+
color: #111827;
|
|
269
|
+
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.18);
|
|
225
270
|
}
|
|
226
271
|
|
|
227
272
|
.sidebar-content {
|
|
228
273
|
overflow-y: auto;
|
|
229
|
-
font-size:
|
|
274
|
+
font-size: 13px;
|
|
275
|
+
line-height: 1.6;
|
|
276
|
+
padding-right: 4px;
|
|
230
277
|
}
|
|
231
|
-
|
|
232
|
-
.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
278
|
+
|
|
279
|
+
.sidebar-content::-webkit-scrollbar {
|
|
280
|
+
width: 6px;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.sidebar-content::-webkit-scrollbar-thumb {
|
|
284
|
+
background: rgba(148, 163, 184, 0.6);
|
|
285
|
+
border-radius: 999px;
|
|
238
286
|
}
|
|
239
|
-
|
|
240
|
-
|
|
287
|
+
|
|
288
|
+
.sidebar-content::-webkit-scrollbar-track {
|
|
289
|
+
background: transparent;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* 抽屉中的提交列表(参考 MUI List / ListItem) ------------- */
|
|
293
|
+
.sidebar-list {
|
|
294
|
+
display: flex;
|
|
295
|
+
flex-direction: column;
|
|
296
|
+
gap: 8px;
|
|
297
|
+
padding: 4px 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.sidebar-item,
|
|
301
|
+
.hour-commit,
|
|
302
|
+
.week-commit {
|
|
303
|
+
padding: 10px 12px;
|
|
304
|
+
border-radius: 8px;
|
|
305
|
+
background: #f9fafb;
|
|
306
|
+
border: 1px solid rgba(148, 163, 184, 0.25);
|
|
307
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
308
|
+
transition:
|
|
309
|
+
background 0.18s ease,
|
|
310
|
+
box-shadow 0.18s ease,
|
|
311
|
+
transform 0.1s ease;
|
|
312
|
+
margin: 8px 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.sidebar-item:hover,
|
|
316
|
+
.hour-commit:hover,
|
|
317
|
+
.week-commit:hover {
|
|
318
|
+
background: #f3f4ff;
|
|
319
|
+
box-shadow: 0 3px 8px rgba(15, 23, 42, 0.12);
|
|
320
|
+
transform: translateY(-1px);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.sidebar-item-header {
|
|
324
|
+
display: flex;
|
|
325
|
+
justify-content: space-between;
|
|
326
|
+
align-items: center;
|
|
241
327
|
font-size: 12px;
|
|
242
|
-
|
|
328
|
+
color: #64748b;
|
|
329
|
+
margin-bottom: 4px;
|
|
243
330
|
}
|
|
244
|
-
|
|
331
|
+
|
|
332
|
+
.sidebar-item-header .author {
|
|
333
|
+
font-weight: 600;
|
|
334
|
+
color: #111827;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.sidebar-item-header .time {
|
|
338
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
|
339
|
+
'Liberation Mono', 'Courier New', monospace;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.sidebar-item-message {
|
|
343
|
+
font-size: 13px;
|
|
344
|
+
color: #111827;
|
|
245
345
|
font-weight: 500;
|
|
246
346
|
word-break: break-word;
|
|
247
347
|
}
|