wukong-gitlog-cli 0.0.5 → 0.0.6

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,16 @@
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
+ ### [0.0.6](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v0.0.5...v0.0.6) (2025-11-27)
6
+
7
+
8
+ ### Features
9
+
10
+ * 🎸 统计非工作时间提交量 ([7b8b0e7](https://github.com/tomatobybike/wukong-gitlog-cli/commit/7b8b0e7f2f85f2cd9e597d9bfd48cb4445937a09))
11
+ * 🎸 lock package.json ([ae94dc7](https://github.com/tomatobybike/wukong-gitlog-cli/commit/ae94dc7c4445347ef0f06141176c9cb7f429ecf0))
12
+ * 🎸 Statistical overtime culture ([c6841bb](https://github.com/tomatobybike/wukong-gitlog-cli/commit/c6841bbff1656e72a5a0129c1cc08f1ee457c768))
13
+ * 🎸 Statistical overtime culture 统计加班文化 ([65ac7ca](https://github.com/tomatobybike/wukong-gitlog-cli/commit/65ac7ca3772ad018dc57623796859ad1f87df861))
14
+
5
15
  ### [0.0.5](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v0.0.4...v0.0.5) (2025-11-27)
6
16
 
7
17
  ### [0.0.4](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v0.0.3...v0.0.4) (2025-11-27)
package/README.md CHANGED
@@ -76,6 +76,12 @@ Command-line options:
76
76
  - `--json` Output JSON
77
77
  - `--format <type>` Output format: `text` | `excel` | `json` (default: `text`)
78
78
  - `--group-by <type>` Group commits by date: `day` | `month`
79
+ - `--overtime` Analyze overtime culture: output counts/percentages for commits outside work hours and on non-workdays (per-person breakdown)
80
+ - `--country <code>` Country/region for holidays (CN|US). Default: `CN`.
81
+ - `--work-start <hour>` Workday start hour. Default: `9`.
82
+ - `--work-end <hour>` Workday end hour. Default: `18`.
83
+ - `--lunch-start <hour>` Lunch break start hour. Default: `12`.
84
+ - `--lunch-end <hour>` Lunch break end hour. Default: `14`.
79
85
  - `--stats` Include a `Stats` sheet in the Excel export
80
86
  - `--gerrit-api <url>` Optional: Gerrit REST API base URL for resolving `{{changeNumber}}` (e.g. `https://gerrit.example.com/gerrit`)
81
87
  - `--gerrit-auth <token>` Optional: Authorization for Gerrit REST API (either `user:pass` for Basic or token string for Bearer)
@@ -212,6 +218,45 @@ ea82531 | tom | 2025-11-25 | feat: 🎸 init
212
218
  741de50 | tom | 2025-11-25 | first commit
213
219
  ```
214
220
 
221
+ You can also analyze overtime culture with the `--overtime` flag to get overall and per-person overtime submission rates (default work window is 09:00-18:00). Example:
222
+
223
+ ```bash
224
+ wukong-gitlog-cli --overtime --limit 500
225
+ ```
226
+
227
+ ## Overtime demo scripts (npm)
228
+
229
+ Below are helpful npm scripts added for quickly running the overtime analysis with commonly used configurations. They are already present in `package.json` and can be run from the project root.
230
+
231
+ ```bash
232
+ # Run a US-focused overtime text report using 10:00-19:00 work hours and a 12:00-13:00 lunch break
233
+ npm run cli:overtime-text-us
234
+
235
+ # Run a US-focused overtime text report and write the outputs into the project parent's output folder
236
+ npm run cli:overtime-text-us-parent
237
+
238
+ # Run a US-focused overtime text report and write the output into ../output (explicit --out-dir)
239
+ npm run cli:overtime-text-us-outdir
240
+
241
+ # Run a CN-focused overtime Excel report (default 9:00-18:00 work hours and 12:00-14:00 lunch)
242
+ npm run cli:overtime-excel-cn
243
+
244
+ # Run a CN-focused overtime Excel report and write outputs to parent output folder
245
+ npm run cli:overtime-excel-cn-parent
246
+
247
+ # Run a CN-focused overtime Excel report and write outputs to ../output via --out-dir
248
+ npm run cli:overtime-excel-cn-outdir
249
+ ```
250
+
251
+ Notes:
252
+
253
+ - Output files are written into `output/` by default. Use `--out-dir` or `--out-parent` to change output location.
254
+ - If you prefer different working hours or country codes, either modify the script in `package.json` or run the CLI manually with flags (e.g. `--work-start`, `--work-end`, `--lunch-start`, `--lunch-end`, `--country`).
255
+
256
+ Formatting note:
257
+
258
+ - The text report is formatted to align columns correctly even when commit messages or author names contain mixed Chinese and English characters (uses `string-width` for display-aware padding).
259
+
215
260
  Example JSON output (from `npm run cli:json-demo`):
216
261
 
217
262
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wukong-gitlog-cli",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Advanced Git commit log exporter with Excel/JSON/TXT output, grouping, stats and CLI.",
5
5
  "keywords": [
6
6
  "git",
@@ -45,6 +45,15 @@
45
45
  "cli:json-demo": "node ./src/cli.mjs --json --limit 5 --out commits.json",
46
46
  "cli:text-demo": "node ./src/cli.mjs --format text --limit 5 --out commits.txt",
47
47
  "cli:text-demo-parent": "node ./src/cli.mjs --out-parent --format text --limit 5 --out commits-parent.txt",
48
+ "cli:overtime-demo": "node ./src/cli.mjs --overtime --format text --limit 200",
49
+ "cli:overtime-text": "node ./src/cli.mjs --overtime --limit 100 --format text --out commits.txt",
50
+ "cli:overtime-excel": "node ./src/cli.mjs --overtime --limit 50 --format excel --out commits.xlsx",
51
+ "cli:overtime-text-us": "node ./src/cli.mjs --overtime --limit 50 --format text --out commits.txt --country US --work-start 10 --work-end 19 --lunch-start 12 --lunch-end 13",
52
+ "cli:overtime-text-us-parent": "node ./src/cli.mjs --out-parent --overtime --limit 50 --format text --out commits.txt --country US --work-start 10 --work-end 19 --lunch-start 12 --lunch-end 13",
53
+ "cli:overtime-text-us-outdir": "node ./src/cli.mjs --out-dir ../output --overtime --limit 50 --format text --out commits.txt --country US --work-start 10 --work-end 19 --lunch-start 12 --lunch-end 13",
54
+ "cli:overtime-excel-cn": "node ./src/cli.mjs --overtime --limit 50 --format excel --out commits.xlsx --country CN",
55
+ "cli:overtime-excel-cn-parent": "node ./src/cli.mjs --out-parent --overtime --limit 50 --format excel --out commits.xlsx --country CN",
56
+ "cli:overtime-excel-cn-outdir": "node ./src/cli.mjs --out-dir ../output --overtime --limit 50 --format excel --out commits.xlsx --country CN",
48
57
  "format": "prettier --write \"src/**/*.{js,mjs}\"",
49
58
  "lint": "eslint src --ext .js,.mjs src",
50
59
  "lint:fix": "eslint src --ext .js,.mjs --fix",
@@ -75,11 +84,13 @@
75
84
  ]
76
85
  },
77
86
  "dependencies": {
78
- "chalk": "^5.3.0",
79
- "commander": "^12.0.0",
80
- "dayjs": "^1.11.10",
81
- "exceljs": "^4.4.0",
82
- "zx": "^7.2.3"
87
+ "chalk": "5.6.0",
88
+ "commander": "12.1.0",
89
+ "dayjs": "1.11.19",
90
+ "date-holidays": "2.1.1",
91
+ "string-width": "5.1.2",
92
+ "exceljs": "4.4.0",
93
+ "zx": "7.2.4"
83
94
  },
84
95
  "devDependencies": {
85
96
  "@trivago/prettier-plugin-sort-imports": "5.2.2",
package/src/cli.mjs CHANGED
@@ -3,6 +3,7 @@ import chalk from 'chalk';
3
3
  import path from 'path';
4
4
  import { getGitLogs } from './git.mjs';
5
5
  import { renderText } from './text.mjs';
6
+ import { analyzeOvertime, renderOvertimeText, renderOvertimeTab, renderOvertimeCsv } from './overtime.mjs';
6
7
  import { exportExcel } from './excel.mjs';
7
8
  import { groupRecords, writeJSON, writeTextFile, outputFilePath } from './utils/index.mjs';
8
9
 
@@ -24,6 +25,12 @@ program
24
25
  .option('--gerrit <prefix>', '显示 Gerrit 地址,支持在 prefix 中使用 {{hash}} 占位符')
25
26
  .option('--gerrit-api <url>', '可选:Gerrit REST API 基础地址,用于解析 changeNumber,例如 `https://gerrit.example.com`')
26
27
  .option('--gerrit-auth <tokenOrUserPass>', '可选:Gerrit API 授权,格式为 `user:pass` 或 `TOKEN`(表示 Bearer token)')
28
+ .option('--overtime', '分析公司加班文化(输出下班时间与非工作日提交占比)')
29
+ .option('--country <code>', '节假日国家:CN 或 US,默认为 CN', 'CN')
30
+ .option('--work-start <hour>', '上班开始小时,默认 9', (v) => parseInt(v, 10), 9)
31
+ .option('--work-end <hour>', '下班小时,默认 18', (v) => parseInt(v, 10), 18)
32
+ .option('--lunch-start <hour>', '午休开始小时,默认 12', (v) => parseInt(v, 10), 12)
33
+ .option('--lunch-end <hour>', '午休结束小时,默认 14', (v) => parseInt(v, 10), 14)
27
34
  .option('--out <file>', '输出文件名(不含路径)')
28
35
  .option('--out-dir <dir>', '自定义输出目录,支持相对路径或绝对路径,例如 `--out-dir ../output`')
29
36
  .option('--out-parent', '将输出目录放到当前工程的父目录的 `output/`(等同于 `--out-dir ../output`)')
@@ -133,6 +140,44 @@ const opts = program.opts();
133
140
  // --- 分组 ---
134
141
  const groups = opts.groupBy ? groupRecords(records, opts.groupBy) : null;
135
142
 
143
+ // --- Overtime analysis ---
144
+ if (opts.overtime) {
145
+ const stats = analyzeOvertime(records, {
146
+ startHour: opts.workStart || opts.workStart === 0 ? opts.workStart : 9,
147
+ endHour: opts.workEnd || opts.workEnd === 0 ? opts.workEnd : 18,
148
+ lunchStart: opts.lunchStart || opts.lunchStart === 0 ? opts.lunchStart : 12,
149
+ lunchEnd: opts.lunchEnd || opts.lunchEnd === 0 ? opts.lunchEnd : 14,
150
+ country: opts.country || 'CN',
151
+ });
152
+ // Output to console
153
+ console.log('\n--- Overtime analysis ---\n');
154
+ console.log(renderOvertimeText(stats));
155
+ // if user requested json format, write stats to file
156
+ if (opts.json || opts.format === 'json') {
157
+ const file = opts.out || 'overtime.json';
158
+ const filepath = outputFilePath(file, outDir);
159
+ writeJSON(filepath, stats);
160
+ console.log(chalk.green(`overtime JSON 已导出: ${filepath}`));
161
+ }
162
+ // Always write human readable overtime text to file (default: overtime.txt)
163
+ const outBase = opts.out ? path.basename(opts.out, path.extname(opts.out)) : 'commits';
164
+ const overtimeFileName = `overtime_${outBase}.txt`;
165
+ const overtimeFile = outputFilePath(overtimeFileName, outDir);
166
+ writeTextFile(overtimeFile, renderOvertimeText(stats));
167
+ // write tab-separated text file for better alignment in editors that use proportional fonts
168
+ const overtimeTabFileName = `overtime_${outBase}.tab.txt`;
169
+ const overtimeTabFile = outputFilePath(overtimeTabFileName, outDir);
170
+ writeTextFile(overtimeTabFile, renderOvertimeTab(stats));
171
+ // write CSV for structured data consumption
172
+ const overtimeCsvFileName = `overtime_${outBase}.csv`;
173
+ const overtimeCsvFile = outputFilePath(overtimeCsvFileName, outDir);
174
+ writeTextFile(overtimeCsvFile, renderOvertimeCsv(stats));
175
+ console.log(chalk.green(`Overtime text 已导出: ${overtimeFile}`));
176
+ console.log(chalk.green(`Overtime table (tabs) 已导出: ${overtimeTabFile}`));
177
+ console.log(chalk.green(`Overtime CSV 已导出: ${overtimeCsvFile}`));
178
+ // don't return — allow other outputs to proceed
179
+ }
180
+
136
181
  // --- JSON ---
137
182
  if (opts.json || opts.format === 'json') {
138
183
  const file = opts.out || 'commits.json';
@@ -0,0 +1,233 @@
1
+ import dayjs from 'dayjs';
2
+ import DateHolidays from 'date-holidays';
3
+ import stringWidth from 'string-width';
4
+
5
+ export function parseCommitDate(d) {
6
+ // d examples: '2025-11-14 23:53:04 +0800' or ISO format
7
+ // Try dayjs parsing; fallback to slicing timezone
8
+ let dt = dayjs(d);
9
+ if (!dt.isValid()) {
10
+ // strip timezone offset and try again
11
+ const m = String(d).match(/^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})/);
12
+ if (m) dt = dayjs(m[1]);
13
+ }
14
+ return dt;
15
+ }
16
+
17
+ function isWeekend(dt) {
18
+ const day = dt.day();
19
+ return day === 0 || day === 6; // Sunday=0, Saturday=6
20
+ }
21
+
22
+ function isOutsideWorkHours(dt, startHour = 9, endHour = 18, lunchStart = 12, lunchEnd = 14) {
23
+ const hour = dt.hour();
24
+ // Working hours are startHour <= hour < endHour, excluding lunchtime lunchStart <= hour < lunchEnd
25
+ const inWorkHours = hour >= startHour && hour < endHour && !(hour >= lunchStart && hour < lunchEnd);
26
+ return !inWorkHours;
27
+ }
28
+
29
+ export function analyzeOvertime(records, opts = {}) {
30
+ const { startHour = 9, endHour = 18, lunchStart = 12, lunchEnd = 14, country = 'CN' } = opts;
31
+ const total = records.length;
32
+
33
+ let outsideWorkCount = 0;
34
+ let nonWorkdayCount = 0;
35
+ let holidayCount = 0;
36
+
37
+ const byAuthor = new Map();
38
+
39
+ let startCommit = null;
40
+ let endCommit = null;
41
+ let latestCommit = null;
42
+
43
+ // init holiday checker for country
44
+ const hd = new DateHolidays();
45
+ try {
46
+ hd.init(String(country).toUpperCase());
47
+ } catch (err) {
48
+ // fallback to CN
49
+ hd.init('CN');
50
+ }
51
+
52
+ records.forEach((r) => {
53
+ const dt = parseCommitDate(r.date);
54
+ if (!dt || !dt.isValid()) return; // skip
55
+
56
+ const outside = isOutsideWorkHours(dt, startHour, endHour, lunchStart, lunchEnd);
57
+ const isHoliday = !!hd.isHoliday(dt.toDate());
58
+ const isNonWork = isWeekend(dt) || isHoliday;
59
+
60
+ if (outside) outsideWorkCount++;
61
+ if (isNonWork) nonWorkdayCount++;
62
+ if (isHoliday) holidayCount++;
63
+
64
+ const authorName = r.author || r.email || 'unknown';
65
+ const key = `${authorName} <${r.email || ''}>`;
66
+ const info = byAuthor.get(key) || {
67
+ name: authorName,
68
+ email: r.email || '',
69
+ total: 0,
70
+ outsideWorkCount: 0,
71
+ nonWorkdayCount: 0,
72
+ };
73
+
74
+ info.total++;
75
+ if (outside) info.outsideWorkCount++;
76
+ if (isNonWork) info.nonWorkdayCount++;
77
+ if (isHoliday) info.holidayCount = (info.holidayCount || 0) + 1;
78
+ byAuthor.set(key, info);
79
+ });
80
+
81
+ const perAuthor = [];
82
+ for (const [, v] of byAuthor) {
83
+ perAuthor.push({
84
+ name: v.name,
85
+ email: v.email,
86
+ total: v.total,
87
+ outsideWorkCount: v.outsideWorkCount,
88
+ nonWorkdayCount: v.nonWorkdayCount,
89
+ holidayCount: v.holidayCount || 0,
90
+ outsideWorkRate: v.total ? +(v.outsideWorkCount / v.total).toFixed(3) : 0,
91
+ nonWorkdayRate: v.total ? +(v.nonWorkdayCount / v.total).toFixed(3) : 0,
92
+ holidayRate: v.total ? +((v.holidayCount || 0) / v.total).toFixed(3) : 0,
93
+ });
94
+ }
95
+
96
+ // compute start/end/latest commit
97
+ // convert to dayjs and find min/max
98
+ const validRecords = records
99
+ .map((r) => ({ ...r, _dt: parseCommitDate(r.date) }))
100
+ .filter((r) => r._dt && r._dt.isValid());
101
+ if (validRecords.length > 0) {
102
+ validRecords.sort((a, b) => a._dt.valueOf() - b._dt.valueOf());
103
+ [startCommit] = validRecords;
104
+ endCommit = validRecords[validRecords.length - 1];
105
+ latestCommit = endCommit;
106
+ // cleanup temp _dt
107
+ for (let i = 0; i < validRecords.length; i++) {
108
+ // copy the object without _dt for safety
109
+ delete validRecords[i]._dt;
110
+ }
111
+ }
112
+
113
+ // sort perAuthor by outsideWorkRate desc
114
+ perAuthor.sort((a, b) => b.outsideWorkRate - a.outsideWorkRate || b.total - a.total);
115
+
116
+ return {
117
+ total,
118
+ outsideWorkCount,
119
+ nonWorkdayCount,
120
+ outsideWorkRate: total ? +(outsideWorkCount / total).toFixed(3) : 0,
121
+ nonWorkdayRate: total ? +(nonWorkdayCount / total).toFixed(3) : 0,
122
+ perAuthor,
123
+ /// 提示:计算 min/max 日期 & latest commit
124
+ startCommit: startCommit || null,
125
+ endCommit: endCommit || null,
126
+ latestCommit: latestCommit || null,
127
+ startHour,
128
+ endHour,
129
+ lunchStart,
130
+ lunchEnd,
131
+ country,
132
+ holidayCount,
133
+ holidayRate: total ? +(holidayCount / total).toFixed(3) : 0,
134
+ };
135
+ }
136
+
137
+ export function renderOvertimeText(stats) {
138
+ const { total, outsideWorkCount, nonWorkdayCount, holidayCount, outsideWorkRate, nonWorkdayRate, holidayRate, perAuthor, startHour, endHour, lunchStart, lunchEnd, country } = stats;
139
+ const { startCommit, endCommit, latestCommit } = stats;
140
+ const lines = [];
141
+
142
+ const formatPercent = (v) => `${(v * 100).toFixed(1)}%`;
143
+ const padDisplayEnd = (s, width) => {
144
+ const t = String(s ?? '');
145
+ const w = stringWidth(t);
146
+ return t + ' '.repeat(Math.max(0, width - w));
147
+ };
148
+ const padDisplayStart = (s, width) => {
149
+ const t = String(s ?? '');
150
+ const w = stringWidth(t);
151
+ return ' '.repeat(Math.max(0, width - w)) + t;
152
+ };
153
+ const cols = {
154
+ name: 22,
155
+ total: 6,
156
+ outside: 9,
157
+ outsideRate: 10,
158
+ nonWork: 10,
159
+ nonWorkRate: 12,
160
+ holiday: 8,
161
+ holidayRate: 10,
162
+ };
163
+ lines.push(`总提交数:${total}`);
164
+ if (startCommit && endCommit) {
165
+ lines.push(`统计区间:${startCommit.date} — ${endCommit.date}`);
166
+ }
167
+ if (latestCommit) {
168
+ lines.push('最晚一次提交:');
169
+ lines.push(` Hash : ${latestCommit.hash}`);
170
+ lines.push(` Author : ${latestCommit.author}`);
171
+ lines.push(` Date : ${latestCommit.date}`);
172
+ lines.push(` Message: ${latestCommit.message}`);
173
+ }
174
+ // country: holiday region, lunchStart/lunchEnd define midday break
175
+ lines.push(`下班时间定义:${startHour}:00 - ${endHour}:00 (午休 ${lunchStart}:00 - ${lunchEnd}:00)`);
176
+ lines.push(`国家假期参考:${String(country).toUpperCase()},节假日提交数:${holidayCount},占比:${(holidayRate * 100).toFixed(1)}%`);
177
+ lines.push(`下班时间(工作时间外)提交数:${outsideWorkCount},占比:${(outsideWorkRate * 100).toFixed(1)}%`);
178
+ lines.push(`非工作日(周末)提交数:${nonWorkdayCount},占比:${(nonWorkdayRate * 100).toFixed(1)}%`);
179
+ lines.push('');
180
+ lines.push('按人员统计:');
181
+ // header
182
+ const header = ` ${padDisplayEnd('Name', cols.name)} | ${padDisplayStart('总数', cols.total)} | ${padDisplayStart('下班外数', cols.outside)} | ${padDisplayStart('下班外占比', cols.outsideRate)} | ${padDisplayStart('非工作日数', cols.nonWork)} | ${padDisplayStart('非工作日占比', cols.nonWorkRate)} | ${padDisplayStart('假日数', cols.holiday)} | ${padDisplayStart('假日占比', cols.holidayRate)}`;
183
+ lines.push(header);
184
+ const totalWidth = cols.name + cols.total + cols.outside + cols.outsideRate + cols.nonWork + cols.nonWorkRate + cols.holiday + cols.holidayRate + 3 * 7; // approximate separator widths
185
+ lines.push('-'.repeat(Math.max(80, totalWidth)));
186
+
187
+ perAuthor.forEach((p) => {
188
+ const name = (p.name || '-').toString();
189
+ const row = ` ${padDisplayEnd(name, cols.name)} | ${padDisplayStart(String(p.total), cols.total)} | ${padDisplayStart(String(p.outsideWorkCount), cols.outside)} | ${padDisplayStart(formatPercent(p.outsideWorkRate), cols.outsideRate)} | ${padDisplayStart(String(p.nonWorkdayCount), cols.nonWork)} | ${padDisplayStart(formatPercent(p.nonWorkdayRate), cols.nonWorkRate)} | ${padDisplayStart(String(p.holidayCount), cols.holiday)} | ${padDisplayStart(formatPercent(p.holidayRate), cols.holidayRate)}`;
190
+ lines.push(row);
191
+ });
192
+ lines.push('');
193
+
194
+ return lines.join('\n');
195
+ }
196
+
197
+ export function renderOvertimeTab(stats) {
198
+ const { total, outsideWorkCount, nonWorkdayCount, holidayCount, outsideWorkRate, nonWorkdayRate, holidayRate, perAuthor, startHour, endHour, lunchStart, lunchEnd, country } = stats;
199
+ const { startCommit, endCommit, latestCommit } = stats;
200
+ const rows = [];
201
+ rows.push(`总提交数:\t${total}`);
202
+ if (startCommit && endCommit) rows.push(`统计区间:\t${startCommit.date} — ${endCommit.date}`);
203
+ if (latestCommit) rows.push(`最晚一次提交:\t${latestCommit.hash}\t${latestCommit.author}\t${latestCommit.date}\t${latestCommit.message}`);
204
+ rows.push(`下班时间定义:\t${startHour}:00 - ${endHour}:00 (午休 ${lunchStart}:00 - ${lunchEnd}:00)`);
205
+ rows.push(`国家假期参考:\t${String(country).toUpperCase()}\t节假日提交数:\t${holidayCount}\t节假日占比:\t${(holidayRate * 100).toFixed(1)}%`);
206
+ rows.push(`下班时间(工作时间外)提交数:\t${outsideWorkCount}\t占比:\t${(outsideWorkRate * 100).toFixed(1)}%`);
207
+ rows.push(`非工作日(周末)提交数:\t${nonWorkdayCount}\t占比:\t${(nonWorkdayRate * 100).toFixed(1)}%`);
208
+ rows.push('');
209
+ rows.push(['Name', '总数', '下班外数', '下班外占比', '非工作日数', '非工作日占比', '假日数', '假日占比'].join('\t'));
210
+ perAuthor.forEach((p) => {
211
+ rows.push([p.name || '-', p.total, p.outsideWorkCount, `${(p.outsideWorkRate * 100).toFixed(1)}%`, p.nonWorkdayCount, `${(p.nonWorkdayRate * 100).toFixed(1)}%`, p.holidayCount || 0, `${((p.holidayCount || 0) / p.total * 100).toFixed(1)}%`].join('\t'));
212
+ });
213
+ return rows.join('\n');
214
+ }
215
+
216
+ function escapeCsv(v) {
217
+ const s = String(v ?? '');
218
+ if (s.includes(',') || s.includes('"') || s.includes('\n')) {
219
+ return `"${s.replace(/"/g, '""')}"`;
220
+ }
221
+ return s;
222
+ }
223
+
224
+ export function renderOvertimeCsv(stats) {
225
+ const { perAuthor } = stats;
226
+ const rows = [];
227
+ rows.push('Name,Total,OutsideCount,OutsideRate,NonWorkdayCount,NonWorkdayRate,HolidayCount,HolidayRate');
228
+ perAuthor.forEach((p) => {
229
+ rows.push(`${escapeCsv(p.name)},${p.total},${p.outsideWorkCount},${(p.outsideWorkRate * 100).toFixed(1)}%,${p.nonWorkdayCount},${(p.nonWorkdayRate * 100).toFixed(1)}%,${p.holidayCount || 0},${((p.holidayCount || 0) / p.total * 100).toFixed(1)}%`);
230
+ });
231
+ return rows.join('\n');
232
+ }
233
+