wukong-gitlog-cli 1.0.14 → 1.0.15

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 CHANGED
@@ -2,6 +2,20 @@
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.15](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.14...v1.0.15) (2025-12-03)
6
+
7
+
8
+ ### Features
9
+
10
+ * 🎸 dayDetailSidebar ([21b99fd](https://github.com/tomatobybike/wukong-gitlog-cli/commit/21b99fd859af45641987dfd5f50a8c5604a380c5))
11
+ * 🎸 each day ([ffa3d5e](https://github.com/tomatobybike/wukong-gitlog-cli/commit/ffa3d5e6192a69a727c747ffa325b56ae5bf78d4))
12
+ * 🎸 hourlyOvertimeChart ([93f62b7](https://github.com/tomatobybike/wukong-gitlog-cli/commit/93f62b7b9906154a16bdcf38bd7b0979d44bebfe))
13
+ * 🎸 latestOvertimeDay ([40cb157](https://github.com/tomatobybike/wukong-gitlog-cli/commit/40cb15788411441306e0c838108817fa36268624))
14
+ * 🎸 next day cutof ([efe6a5f](https://github.com/tomatobybike/wukong-gitlog-cli/commit/efe6a5f41e54ca0fdadb3c0071f1fcf7a286e6c2))
15
+ * 🎸 onDayClick ([9132b54](https://github.com/tomatobybike/wukong-gitlog-cli/commit/9132b5423449ceb8a964d813dfd4931834993913))
16
+ * 🎸 renderKpi ([e26cc0d](https://github.com/tomatobybike/wukong-gitlog-cli/commit/e26cc0deac83a7e9bccaae81ba572da0e39e82b3))
17
+ * 🎸 week range ([cd565da](https://github.com/tomatobybike/wukong-gitlog-cli/commit/cd565dac98b6c38442eeacdd6dd423b93d657ab0))
18
+
5
19
  ### [1.0.14](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.13...v1.0.14) (2025-12-02)
6
20
 
7
21
 
package/README.md CHANGED
@@ -53,6 +53,10 @@ wukong-gitlog-cli --help
53
53
 
54
54
  ## Usage
55
55
 
56
+ ```bash
57
+ wukong-gitlog-cli --overtime --serve --port 5555
58
+ ```
59
+
56
60
  ```bash
57
61
  wukong-gitlog-cli [options]
58
62
  ```
package/README.zh-CN.md CHANGED
@@ -55,6 +55,10 @@ wukong-gitlog-cli --help
55
55
 
56
56
  ## 🚀 使用方法
57
57
 
58
+ ```bash
59
+ wukong-gitlog-cli --overtime --serve --port 5555
60
+ ```
61
+
58
62
  ```bash
59
63
  wukong-gitlog-cli [options]
60
64
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wukong-gitlog-cli",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "description": "Advanced Git commit log exporter with Excel/JSON/TXT output, grouping, stats and CLI.",
5
5
  "keywords": [
6
6
  "git",
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('data/overtime-monthly.mjs', outDir)
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
- const overnightCutoff = Number.isFinite(opts.overnightCutoff) ? opts.overnightCutoff : 6
392
- const latestByDay = dayKeys2.map((k) => {
393
- const list = dayGroups2[k]
394
- const vals = list
395
- .map((r) => ({ r, _dt: new Date(r.date) }))
396
- .filter((x) => x._dt && !Number.isNaN(x._dt.valueOf()))
397
- .sort((a, b) => a._dt.valueOf() - b._dt.valueOf())
398
- const last = vals.length > 0 ? vals[vals.length - 1] : null
399
- const hour = last ? new Date(last.r.date).getHours() : null
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
- .filter((h) => h >= 0 && h < overnightCutoff)
407
- const earlyMax = earlyHours.length > 0 ? Math.max(...earlyHours) : null
408
- const normalized = typeof earlyMax === 'number' ? 24 + earlyMax : null
409
- const latestHourNormalized = Math.max(
410
- typeof hour === 'number' ? hour : -1,
411
- typeof normalized === 'number' ? normalized : -1
412
- )
413
- return { date: k, latestHour: hour, latestHourNormalized: latestHourNormalized >= 0 ? latestHourNormalized : null }
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(configFile, `export default ${JSON.stringify(cfg, null, 2)};\n`)
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 `
@@ -212,9 +212,10 @@ function drawHourlyOvertime(stats, onHourClick) {
212
212
  // showSideBarForHour 实现
213
213
  function showSideBarForHour(hour, commitsOrCount) {
214
214
  // 支持传入 number(仅次数)或 array(详细 commit 列表)
215
- const sidebar = document.getElementById('hourDetailSidebar')
216
- const titleEl = document.getElementById('hourSidebarTitle')
217
- const contentEl = document.getElementById('hourSidebarContent')
215
+ // 统一复用通用详情侧栏 DOM
216
+ const sidebar = document.getElementById('dayDetailSidebar')
217
+ const titleEl = document.getElementById('sidebarTitle')
218
+ const contentEl = document.getElementById('sidebarContent')
218
219
 
219
220
  // 兼容未传入侧栏 DOM 的情况(优雅降级)
220
221
  if (!sidebar || !titleEl || !contentEl) {
@@ -260,14 +261,6 @@ function showSideBarForHour(hour, commitsOrCount) {
260
261
  sidebar.classList.add('show')
261
262
  }
262
263
 
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
264
  // 简单的 HTML 转义,防止 XSS 与布局断裂
272
265
  function escapeHtml(str = '') {
273
266
  return String(str)
@@ -301,7 +294,7 @@ function drawOutsideVsInside(stats) {
301
294
  return chart
302
295
  }
303
296
 
304
- function drawDailyTrend(commits) {
297
+ function drawDailyTrend(commits, onDayClick) {
305
298
  if (!Array.isArray(commits) || commits.length === 0) return null
306
299
 
307
300
  // 聚合每日提交数量
@@ -398,10 +391,60 @@ function drawDailyTrend(commits) {
398
391
  ]
399
392
  })
400
393
 
394
+ // 点击某一天,打开抽屉显示当日 commits
395
+ if (typeof onDayClick === 'function') {
396
+ chart.on('click', (params) => {
397
+ const idx = params.dataIndex
398
+ const date = labels[idx]
399
+ const count = data[idx]
400
+ const dayCommits = commits.filter(
401
+ (c) => new Date(c.date).toISOString().slice(0, 10) === date
402
+ )
403
+ onDayClick(date, count, dayCommits)
404
+ })
405
+ }
406
+
401
407
  return chart
402
408
  }
403
409
 
404
- function drawWeeklyTrend(weekly) {
410
+ function showSideBarForWeek(period, weeklyItem, commits = []) {
411
+ // 统一复用通用详情侧栏 DOM
412
+ const sidebar = document.getElementById('dayDetailSidebar')
413
+ const titleEl = document.getElementById('sidebarTitle')
414
+ const contentEl = document.getElementById('sidebarContent')
415
+
416
+ titleEl.innerHTML = `📅 周期:<b>${period}</b>`
417
+
418
+ let html = `
419
+ <div style="padding:6px 0;">
420
+ 加班次数:<b>${weeklyItem.outsideWorkCount}</b><br/>
421
+ 占比:<b>${(weeklyItem.outsideWorkRate * 100).toFixed(1)}%</b>
422
+ </div>
423
+ <hr/>
424
+ `
425
+
426
+ if (!commits.length) {
427
+ html += `<div style="padding:10px;color:#777;">该周无提交记录</div>`
428
+ } else {
429
+ html += commits
430
+ .map((c) => {
431
+ return `
432
+ <div class="week-commit">
433
+ <div class="meta">👤 <b>${escapeHtml(c.author || 'unknown')}</b> · 🕒 ${
434
+ c.date
435
+ }</div>
436
+ <div class="msg">${escapeHtml((c.message || '').replace(/\n/g, ' '))}</div>
437
+ </div>
438
+ `
439
+ })
440
+ .join('')
441
+ }
442
+
443
+ contentEl.innerHTML = html
444
+ sidebar.classList.add('show')
445
+ }
446
+
447
+ function drawWeeklyTrend(weekly, commits, onWeekClick) {
405
448
  if (!Array.isArray(weekly) || weekly.length === 0) return null
406
449
 
407
450
  const labels = weekly.map((w) => w.period)
@@ -409,13 +452,16 @@ function drawWeeklyTrend(weekly) {
409
452
  const dataCount = weekly.map((w) => w.outsideWorkCount)
410
453
 
411
454
  const el = document.getElementById('weeklyTrendChart')
412
- // eslint-disable-next-line no-undef
413
455
  const chart = echarts.init(el)
414
456
 
415
457
  chart.setOption({
416
458
  tooltip: {
417
459
  trigger: 'axis',
418
460
  formatter: (params) => {
461
+ const pp = params[0]
462
+ const weekItem = weekly[pp.dataIndex]
463
+ const { start, end } = weekItem.range
464
+
419
465
  const rate = params.find((p) => p.seriesName.includes('%'))?.data
420
466
  const count = params.find((p) => p.seriesName.includes('次数'))?.data
421
467
 
@@ -425,22 +471,20 @@ function drawWeeklyTrend(weekly) {
425
471
  if (rate >= 20) level = '🔴 严重(≥20%)'
426
472
 
427
473
  return `
428
- <div style="font-size:13px; line-height:1.5;">
429
- <b>${params[0].axisValue}</b><br/>
430
- 加班占比:<b>${rate}%</b><br/>
431
- 加班次数:${count} 次<br/>
432
- 等级:${level}
433
- </div>
434
- `
474
+ <div style="font-size:13px; line-height:1.5;">
475
+ <b>${params[0].axisValue}</b><br/>
476
+ 📅 周区间:<b>${start} ~ ${end}</b><br/>
477
+ 加班占比:<b>${rate}%</b><br/>
478
+ 加班次数:${count} 次<br/>
479
+ 等级:${level}
480
+ </div>
481
+ `
435
482
  }
436
483
  },
437
484
 
438
- legend: {
439
- top: 10
440
- },
485
+ legend: { top: 10 },
441
486
 
442
487
  xAxis: { type: 'category', data: labels },
443
-
444
488
  yAxis: [
445
489
  { type: 'value', min: 0, max: 100, name: '占比(%)' },
446
490
  { type: 'value', name: '次数', min: 0 }
@@ -451,26 +495,22 @@ function drawWeeklyTrend(weekly) {
451
495
  type: 'line',
452
496
  name: '加班占比(%)',
453
497
  data: dataRate,
454
-
455
- // ⭐ 区间背景(与 monthly/daily 对齐)
456
498
  markArea: {
457
499
  data: [
458
500
  [
459
501
  { yAxis: 0 },
460
- { yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } } // 绿色
502
+ { yAxis: 10, itemStyle: { color: 'rgba(76, 175, 80, 0.15)' } }
461
503
  ],
462
504
  [
463
505
  { yAxis: 10 },
464
- { yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } } // 橙色
506
+ { yAxis: 20, itemStyle: { color: 'rgba(251, 140, 0, 0.15)' } }
465
507
  ],
466
508
  [
467
509
  { yAxis: 20 },
468
- { yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } } // 红色
510
+ { yAxis: 100, itemStyle: { color: 'rgba(211, 47, 47, 0.15)' } }
469
511
  ]
470
512
  ]
471
513
  },
472
-
473
- // ⭐ 阈值线
474
514
  markLine: {
475
515
  symbol: ['none', 'arrow'],
476
516
  data: [
@@ -493,17 +533,36 @@ function drawWeeklyTrend(weekly) {
493
533
  name: '加班次数',
494
534
  data: dataCount,
495
535
  yAxisIndex: 1,
496
-
497
- // 次数线使用默认蓝色,避免干扰等级颜色区间
498
536
  smooth: true
499
537
  }
500
538
  ]
501
539
  })
502
540
 
541
+ // ⭐ 点击事件:从 commits 过滤该周提交
542
+ chart.on('click', (p) => {
543
+ const idx = p.dataIndex
544
+ const w = weekly[idx]
545
+
546
+
547
+ const start = new Date(w.range.start)
548
+ const end = new Date(w.range.end)
549
+ end.setHours(23, 59, 59, 999) // 包含当天
550
+
551
+ const weeklyCommits = commits.filter((c) => {
552
+ const d = new Date(c.date)
553
+ return d >= start && d <= end
554
+ })
555
+
556
+ // 回调交给外面决定如何打开侧栏
557
+ if (typeof onWeekClick === 'function') {
558
+ onWeekClick(w.period, w, weeklyCommits)
559
+ }
560
+ })
561
+
503
562
  return chart
504
563
  }
505
564
 
506
- function drawMonthlyTrend(monthly) {
565
+ function drawMonthlyTrend(monthly, commits, onMonthClick) {
507
566
  if (!Array.isArray(monthly) || monthly.length === 0) return null
508
567
 
509
568
  const labels = monthly.map((m) => m.period)
@@ -599,10 +658,27 @@ function drawMonthlyTrend(monthly) {
599
658
  ]
600
659
  })
601
660
 
661
+ // 点击某个月份,打开抽屉显示该月的所有 commits
662
+ if (typeof onMonthClick === 'function' && Array.isArray(commits)) {
663
+ chart.on('click', (params) => {
664
+ const idx = params.dataIndex
665
+ const ym = labels[idx] // 'YYYY-MM'
666
+ const monthCommits = commits.filter((c) => {
667
+ const d = new Date(c.date)
668
+ const m = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(
669
+ 2,
670
+ '0'
671
+ )}`
672
+ return m === ym
673
+ })
674
+ onMonthClick(ym, monthCommits.length, monthCommits)
675
+ })
676
+ }
677
+
602
678
  return chart
603
679
  }
604
680
 
605
- function drawLatestHourDaily(latestByDay) {
681
+ function drawLatestHourDaily(latestByDay, commits, onDayClick) {
606
682
  if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
607
683
 
608
684
  const labels = latestByDay.map((d) => d.date)
@@ -680,6 +756,8 @@ function drawLatestHourDaily(latestByDay) {
680
756
  type: 'line',
681
757
  name: '每日最晚提交小时',
682
758
  data,
759
+ // 让折线在 null 点之间连起来,避免视觉上“断裂”
760
+ connectNulls: true,
683
761
  markLine: {
684
762
  symbol: ['none', 'arrow'],
685
763
  data: [
@@ -715,10 +793,28 @@ function drawLatestHourDaily(latestByDay) {
715
793
  ]
716
794
  })
717
795
 
796
+ // 点击某一天的最晚提交时间点,打开抽屉显示该日 commits
797
+ if (typeof onDayClick === 'function' && Array.isArray(commits)) {
798
+ // 预聚合:按天收集 commits
799
+ const dayCommitsMap = {}
800
+ commits.forEach((c) => {
801
+ const d = new Date(c.date).toISOString().slice(0, 10)
802
+ if (!dayCommitsMap[d]) dayCommitsMap[d] = []
803
+ dayCommitsMap[d].push(c)
804
+ })
805
+
806
+ chart.on('click', (params) => {
807
+ const idx = params.dataIndex
808
+ const date = labels[idx]
809
+ const list = dayCommitsMap[date] || []
810
+ onDayClick(date, list.length, list)
811
+ })
812
+ }
813
+
718
814
  return chart
719
815
  }
720
816
 
721
- function drawDailySeverity(latestByDay) {
817
+ function drawDailySeverity(latestByDay, commits, onDayClick) {
722
818
  if (!Array.isArray(latestByDay) || latestByDay.length === 0) return null
723
819
 
724
820
  const labels = latestByDay.map((d) => d.date)
@@ -730,7 +826,11 @@ function drawDailySeverity(latestByDay) {
730
826
  : (d.latestHour ?? null)
731
827
  )
732
828
 
733
- const sev = raw.map((v) => (v == null ? null : Math.max(0, Number(v) - endH)))
829
+ // 若某天 latestHourNormalized 为空,表示「没有下班后到次日上班前的提交」,
830
+ // 这里按 0 小时加班处理,保证折线连续。
831
+ const sev = raw.map((v) =>
832
+ v == null ? 0 : Math.max(0, Number(v) - endH)
833
+ )
734
834
 
735
835
  const el = document.getElementById('dailySeverityChart')
736
836
  // eslint-disable-next-line no-undef
@@ -746,15 +846,6 @@ function drawDailySeverity(latestByDay) {
746
846
  const overtime = p.data
747
847
  const rawHour = raw[p.dataIndex] // 原始 latestHour 或 latestHourNormalized
748
848
 
749
- if (overtime == null) {
750
- return `
751
- <div style="font-size:13px;">
752
- <b>${date}</b><br/>
753
- 无数据
754
- </div>
755
- `
756
- }
757
-
758
849
  return `
759
850
  <div style="font-size:13px;">
760
851
  <b>${date}</b><br/>
@@ -781,6 +872,8 @@ function drawDailySeverity(latestByDay) {
781
872
  type: 'line',
782
873
  name: '超过下班小时数',
783
874
  data: sev,
875
+ // 连续显示 0 小时加班的日期,避免折线断开
876
+ connectNulls: true,
784
877
 
785
878
  // ⭐ 加班区域背景
786
879
  markArea: {
@@ -828,6 +921,23 @@ function drawDailySeverity(latestByDay) {
828
921
  ]
829
922
  })
830
923
 
924
+ // 点击某一天的「超过下班小时数」点,打开抽屉显示该日 commits
925
+ if (typeof onDayClick === 'function' && Array.isArray(commits)) {
926
+ const dayCommitsMap = {}
927
+ commits.forEach((c) => {
928
+ const d = new Date(c.date).toISOString().slice(0, 10)
929
+ if (!dayCommitsMap[d]) dayCommitsMap[d] = []
930
+ dayCommitsMap[d].push(c)
931
+ })
932
+
933
+ chart.on('click', (params) => {
934
+ const idx = params.dataIndex
935
+ const date = labels[idx]
936
+ const list = dayCommitsMap[date] || []
937
+ onDayClick(date, list.length, list)
938
+ })
939
+ }
940
+
831
941
  return chart
832
942
  }
833
943
 
@@ -1002,8 +1112,8 @@ function showDayDetailSidebar(date, count, commits) {
1002
1112
  (c) => `
1003
1113
  <div style="margin-bottom:12px;">
1004
1114
  <div>👤 <b>${c.author}</b></div>
1005
- <div>🕒 ${c.time}</div>
1006
- <div>💬 ${c.msg}</div>
1115
+ <div>🕒 ${c.time || c.date}</div>
1116
+ <div>💬 ${c.msg ||c.message}</div>
1007
1117
  </div>
1008
1118
  <hr/>
1009
1119
  `
@@ -1013,21 +1123,59 @@ function showDayDetailSidebar(date, count, commits) {
1013
1123
  sidebar.classList.add('show')
1014
1124
  }
1015
1125
 
1016
- // 关闭按钮
1017
- document.getElementById('sidebarClose').onclick = () => {
1018
- document.getElementById('dayDetailSidebar').classList.remove('show')
1019
- }
1020
-
1021
1126
  function renderKpi(stats) {
1022
1127
  const el = document.getElementById('kpiContent')
1023
1128
  if (!el || !stats) return
1024
1129
  const latest = stats.latestCommit
1025
1130
  const latestHour = stats.latestCommitHour
1026
- const latestOut = stats.latestOutsideCommit
1027
- const latestOutHour =
1028
- stats.latestOutsideCommitHour ??
1029
- (latestOut ? new Date(latestOut.date).getHours() : null)
1131
+
1132
+ // 使用 cutoff + 上下班时间,重新在全部 commits 中计算「加班最晚一次提交」
1030
1133
  const cutoff = window.__overnightCutoff ?? 6
1134
+ const startHour =
1135
+ (typeof stats.startHour === 'number' && stats.startHour >= 0
1136
+ ? stats.startHour
1137
+ : 9)
1138
+ const endHour =
1139
+ (typeof stats.endHour === 'number' && stats.endHour >= 0
1140
+ ? stats.endHour
1141
+ : window.__overtimeEndHour ?? 18)
1142
+
1143
+ let latestOut = null
1144
+ let latestOutHour = null
1145
+ let maxSeverity = -1
1146
+
1147
+ if (Array.isArray(commitsAll) && commitsAll.length > 0) {
1148
+ commitsAll.forEach((c) => {
1149
+ const d = new Date(c.date)
1150
+ if (!d || Number.isNaN(d.valueOf())) return
1151
+ const h = d.getHours()
1152
+
1153
+ // 只看「当日下班后」以及「次日凌晨 cutoff 之前,且仍在上班前」的提交
1154
+ let sev = null
1155
+ if (h >= endHour && h < 24) {
1156
+ // 当晚:直接按 h - endHour 计算
1157
+ sev = h - endHour
1158
+ } else if (h >= 0 && h < cutoff && h < startHour) {
1159
+ // 次日凌晨:视作跨天,加上 24
1160
+ sev = 24 - endHour + h
1161
+ }
1162
+
1163
+ if (sev != null && sev >= 0 && sev > maxSeverity) {
1164
+ maxSeverity = sev
1165
+ latestOut = c
1166
+ latestOutHour = h
1167
+ }
1168
+ })
1169
+ }
1170
+
1171
+ // 若按 cutoff 没算出结果,则退回到原来的 stats.latestOutsideCommit
1172
+ if (!latestOut && stats.latestOutsideCommit) {
1173
+ latestOut = stats.latestOutsideCommit
1174
+ latestOutHour =
1175
+ stats.latestOutsideCommitHour ??
1176
+ (latestOut ? new Date(latestOut.date).getHours() : null)
1177
+ }
1178
+
1031
1179
  const html = [
1032
1180
  `<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
1181
  `<div class="hr"></div>`,
@@ -1050,7 +1198,7 @@ function groupCommitsByHour(commits) {
1050
1198
  return byHour
1051
1199
  }
1052
1200
 
1053
- ;(async function main() {
1201
+ async function main() {
1054
1202
  const { commits, stats, weekly, monthly, latestByDay, config } =
1055
1203
  await loadData()
1056
1204
  commitsAll = commits
@@ -1064,21 +1212,145 @@ function groupCommitsByHour(commits) {
1064
1212
  initTableControls()
1065
1213
  updatePager()
1066
1214
  renderCommitsTablePage()
1067
- // 使用举例
1068
- const hourCommitsDetail = groupCommitsByHour(commits)
1069
1215
 
1070
1216
  drawHourlyOvertime(stats, (hour, count) => {
1217
+ // 使用举例
1218
+ const hourCommitsDetail = groupCommitsByHour(commits)
1071
1219
  // 将 commit 列表传给侧栏(若没有详情,则传空数组)
1072
1220
  showSideBarForHour(hour, hourCommitsDetail[hour] || [])
1073
1221
  })
1074
1222
  drawOutsideVsInside(stats)
1075
- drawDailyTrend(commits)
1076
- drawWeeklyTrend(weekly)
1077
- drawMonthlyTrend(monthly)
1078
- drawLatestHourDaily(latestByDay)
1079
- drawDailySeverity(latestByDay)
1223
+
1224
+ // 按日提交趋势:点击某天打开抽屉,显示当日所有 commits
1225
+ drawDailyTrend(commits, showDayDetailSidebar)
1226
+
1227
+ // 周趋势:保持原有点击行为(显示该周详情)
1228
+ drawWeeklyTrend(weekly, commits, showSideBarForWeek)
1229
+
1230
+ // 月趋势(加班占比):点击某个月打开抽屉,显示该月所有 commits
1231
+ drawMonthlyTrend(monthly, commits, showDayDetailSidebar)
1232
+
1233
+ // 每日最晚提交时间(小时):点击某天打开抽屉,显示当日所有 commits
1234
+ drawLatestHourDaily(latestByDay, commits, showDayDetailSidebar)
1235
+
1236
+ // 每日超过下班的小时数:点击某天打开抽屉,显示当日所有 commits
1237
+ drawDailySeverity(latestByDay, commits, showDayDetailSidebar)
1238
+
1080
1239
  const daily = drawDailyTrendSeverity(commits, weekly, showDayDetailSidebar)
1081
1240
 
1082
1241
  console.log('最累的一天:', daily.analysis.mostTiredDay)
1242
+ computeAndRenderLatestOvertime(latestByDay)
1083
1243
  renderKpi(stats)
1084
- })()
1244
+ }
1245
+
1246
+ // 基于 latestByDay + cutoff/endHour 统计「最晚加班的一天 / 一周 / 一月」
1247
+ function computeAndRenderLatestOvertime(latestByDay) {
1248
+ if (!Array.isArray(latestByDay) || latestByDay.length === 0) return
1249
+
1250
+ const endH = window.__overtimeEndHour || 18
1251
+
1252
+ // 每天的 latestHourNormalized → 超出下班的小时数
1253
+ const dailyOvertime = latestByDay
1254
+ .map((d) => {
1255
+ const v =
1256
+ typeof d.latestHourNormalized === 'number'
1257
+ ? d.latestHourNormalized
1258
+ : typeof d.latestHour === 'number'
1259
+ ? d.latestHour
1260
+ : null
1261
+ if (v == null) return null
1262
+ const overtime = Math.max(0, Number(v) - endH)
1263
+ return { date: d.date, overtime, raw: v }
1264
+ })
1265
+ .filter(Boolean)
1266
+
1267
+ if (!dailyOvertime.length) return
1268
+
1269
+ // 1) 最晚加班的一天(超出下班小时数最大,若相同取日期更晚)
1270
+ const dailySorted = [...dailyOvertime].sort((a, b) => {
1271
+ if (b.overtime !== a.overtime) return b.overtime - a.overtime
1272
+ return new Date(b.date) - new Date(a.date)
1273
+ })
1274
+ const worstDay = dailySorted[0]
1275
+ const dayEl = document.getElementById('latestOvertimeDay')
1276
+ if (dayEl) {
1277
+ dayEl.innerHTML = `⏰ 最晚加班的一天:<b>${worstDay.date}</b>(超过下班 <b>${worstDay.overtime.toFixed(
1278
+ 2
1279
+ )}</b> 小时,逻辑时间约 ${worstDay.raw.toFixed(2)} 点)`
1280
+ }
1281
+
1282
+ // 工具:根据日期字符串计算 ISO 周 key:YYYY-Www
1283
+ const getIsoWeekKey = (dStr) => {
1284
+ const d = new Date(dStr)
1285
+ if (Number.isNaN(d.valueOf())) return null
1286
+ const target = new Date(
1287
+ Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())
1288
+ )
1289
+ const dayNum = target.getUTCDay() || 7 // Sunday=0
1290
+ target.setUTCDate(target.getUTCDate() + 4 - dayNum)
1291
+ const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
1292
+ const weekNo = Math.ceil(((target - yearStart) / 86400000 + 1) / 7)
1293
+ const year = target.getUTCFullYear()
1294
+ return `${year}-W${String(weekNo).padStart(2, '0')}`
1295
+ }
1296
+
1297
+ // 2) 按周聚合:每周取「该周内任意一天的最大加班时长」
1298
+ const weekMap = new Map()
1299
+ dailyOvertime.forEach((d) => {
1300
+ const key = getIsoWeekKey(d.date)
1301
+ if (!key) return
1302
+ const cur = weekMap.get(key)
1303
+ if (!cur || d.overtime > cur.overtime) {
1304
+ weekMap.set(key, d)
1305
+ }
1306
+ })
1307
+
1308
+ if (weekMap.size) {
1309
+ const weeks = Array.from(weekMap.entries()).sort((a, b) => {
1310
+ if (b[1].overtime !== a[1].overtime) return b[1].overtime - a[1].overtime
1311
+ return new Date(b[1].date) - new Date(a[1].date)
1312
+ })
1313
+ const [weekKey, weekInfo] = weeks[0]
1314
+ const weekEl = document.getElementById('latestOvertimeWeek')
1315
+ if (weekEl) {
1316
+ weekEl.innerHTML = `⏰ 最晚加班的一周:<b>${weekKey}</b>(代表日期 ${weekInfo.date},超过下班 <b>${weekInfo.overtime.toFixed(
1317
+ 2
1318
+ )}</b> 小时)`
1319
+ }
1320
+ }
1321
+
1322
+ // 3) 按月聚合:每月取「该月任意一天的最大加班时长」
1323
+ const monthMap = new Map()
1324
+ dailyOvertime.forEach((d) => {
1325
+ const dt = new Date(d.date)
1326
+ if (Number.isNaN(dt.valueOf())) return
1327
+ const key = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(
1328
+ 2,
1329
+ '0'
1330
+ )}`
1331
+ const cur = monthMap.get(key)
1332
+ if (!cur || d.overtime > cur.overtime) {
1333
+ monthMap.set(key, d)
1334
+ }
1335
+ })
1336
+
1337
+ if (monthMap.size) {
1338
+ const months = Array.from(monthMap.entries()).sort((a, b) => {
1339
+ if (b[1].overtime !== a[1].overtime) return b[1].overtime - a[1].overtime
1340
+ return new Date(b[1].date) - new Date(a[1].date)
1341
+ })
1342
+ const [monthKey, monthInfo] = months[0]
1343
+ const monthEl = document.getElementById('latestOvertimeMonth')
1344
+ if (monthEl) {
1345
+ monthEl.innerHTML = `⏰ 最晚加班的月份:<b>${monthKey}</b>(代表日期 ${monthInfo.date},超过下班 <b>${monthInfo.overtime.toFixed(
1346
+ 2
1347
+ )}</b> 小时)`
1348
+ }
1349
+ }
1350
+ }
1351
+
1352
+ // 关闭按钮
1353
+ document.getElementById('sidebarClose').onclick = () => {
1354
+ document.getElementById('dayDetailSidebar').classList.remove('show')
1355
+ }
1356
+ 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,15 @@
98
101
  <code>/data/</code>.</small
99
102
  >
100
103
  </footer>
101
- <!-- 右侧滑出的详情侧栏 -->
104
+ <!-- 通用:右侧滑出的详情侧栏(小时 / 天 / 周 复用同一个 DOM) -->
102
105
  <div id="dayDetailSidebar" class="sidebar">
103
106
  <div class="sidebar-header">
104
107
  <span id="sidebarTitle"></span>
105
108
  <button id="sidebarClose">×</button>
106
109
  </div>
107
-
108
110
  <div id="sidebarContent" class="sidebar-content"></div>
109
111
  </div>
110
112
 
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
113
  <script type="module" src="/app.js"></script>
122
114
  </body>
123
115
  </html>