diff --git a/.gitignore b/.gitignore index 6ec49e1..c823ab5 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ logs CLAUDE.md AGENTS.md .cursor/ -.claude/ \ No newline at end of file +.claude/ +report.* diff --git a/README.md b/README.md index d51cc8f..c7d0329 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ code996 是一个分析工具,它可以统计 Git 项目的 commit 时间分 当你入职新公司,跑一下 `npx code996`,就可以看到数据背后的真相,从而判断这家公司的真实加班文化。 +与其痛苦三个月,不如早点看清真相!别等到试用期结束才后悔! + ## 预览 ### 查看核心结果 @@ -30,16 +32,36 @@ code996 是一个分析工具,它可以统计 Git 项目的 commit 时间分 加班情况分析图 -### 综合建议 +### 月度趋势分析 + +月度趋势分析图 + +### 导出报告(新功能) ✨ + +支持将分析结果导出为多种精美格式: -综合建议图 +| 格式 | 特点 | 适用场景 | +|------|------|----------| +| 📄 TXT | Unicode 艺术边框 | 终端查看、日志记录 | +| 📝 Markdown | 表格和徽章 | GitHub/文档分享 | +| 🎨 HTML | 渐变背景、交互卡片 | 浏览器查看、邮件 | +| 🖼️ SVG | 矢量图、可缩放 | 高质量图片 | +| 📸 PNG | 1200x800 位图 | 演示文稿、社交媒体 | + +```bash +# 导出精美的 HTML 报告 +code996 -f html + +# 导出并自动在浏览器中打开 +code996 -f html --open +``` ## 🚀 快速开始 无需安装,快速使用: ```bash -# 在当前仓库运行分析(默认查询以上次提交为终点开始365天的commit) +# 在当前仓库或仓库上级目录运行(默认查询以上次提交为终点开始365天的commit) npx code996 ``` @@ -53,38 +75,114 @@ npm i -g code996 code996 ``` +## 🤖 智能分析模式 + +code996 会根据上下文自动选择最合适的分析模式: + +- **在Git仓库中运行** → 单仓库深度分析 +- **目录下有多个仓库** → 自动进入多仓库分析模式 + +```bash +# 智能检测,自动选择分析模式 +code996 # 智能检测当前环境 +code996 /path/to/repo # 分析指定仓库 +code996 /proj1 /proj2 # 自动进入多仓库模式 +code996 /workspace # 自动扫描子目录 +``` + ## 📖 详细使用说明 -### 命令与选项 +### 基础命令 -- `trend`:查看月度996指数和工作时间的变化趋势 - `help`:显示帮助信息 +#### 时间范围选项 + - `-y, --year `:指定年份或年份范围(推荐) - 单年格式:`2025` → 分析 2025-01-01 至 2025-12-31 - 范围格式:`2023-2025` → 分析 2023-01-01 至 2025-12-31 - `-s, --since `:自定义开始日期 (YYYY-MM-DD) - `-u, --until `:自定义结束日期 (YYYY-MM-DD) - `--all-time`:覆盖整个仓库历史数据 + +#### 筛选与展示选项 + +- `-H, --hours `:手动指定标准工作时间(例如:9-18 或 9.5-18.5)**重要:建议使用该参数,以获取更正确的评价结果** +- `--half-hour`:以半小时粒度展示时间分布(默认按小时展示)**统计更精确** +- `--timezone `:指定时区进行分析(例如:+0800、-0700)**适用于跨时区团队项目** - `--self`:仅统计当前 Git 用户的提交记录 +- `--ignore-author `:排除匹配特定正则表达式的作者(例如:排除 bot 或 jenkins) +- `--ignore-msg `:排除 Commit Message 中包含特定关键词的提交(例如:排除 merge 或 lint) + +#### 导出与分享选项 ✨ 新功能 + +- `-f, --format `:导出报告为指定格式 + - `txt`:纯文本格式(默认) + - `md`:Markdown 格式,适合文档平台 + - `html`:HTML 网页格式,美观的可视化报告 + - `svg`:SVG 矢量图,可缩放 + - `png`:PNG 图片,适合演示文稿 +- `-o, --open`:自动在浏览器中打开可视化分析链接 ### 使用示例 ```bash -# 按年份分析 +# ===== 单仓库分析(智能模式) ===== +code996 # 分析当前仓库(最近一年) +code996 /path/to/repo # 分析指定仓库 code996 -y 2025 # 分析2025年 code996 -y 2023-2025 # 分析2023-2025年 +code996 --all-time # 查询整个仓库历史 +code996 --self # 只分析当前用户的提交 +code996 --self -y 2025 # 分析自己在2025年的提交 -# 精确时间范围 -code996 --since 2024-01-01 --until 2024-06-30 +# ===== 多仓库分析(智能自动检测) ===== +code996 # 如果子目录有多个仓库,自动进入多仓库模式 +code996 /path/proj1 /path/proj2 # 传入多个路径,自动分析多个仓库 +code996 /workspace # 扫描指定目录的所有子仓库 +code996 /workspace -y 2025 # 分析2025年的数据和趋势 +code996 --self # 只统计当前用户在所有仓库中的提交 + +# 精细分析(半小时粒度) +code996 --half-hour # 以半小时粒度展示时间分布 +code996 -y 2025 --half-hour # 结合年份分析,精细展示 +code996 /proj1 /proj2 --half-hour # 多仓库分析,半小时粒度展示 + +# 跨时区项目分析 +code996 --timezone="+0800" # 只分析东八区(中国)的提交 +code996 --timezone="-0700" # 只分析西七区(美国西海岸)的提交 +code996 -y 2025 --timezone="+0800" # 分析2025年特定时区的提交 + +# 过滤噪音数据(排除 CI/CD 机器人、合并提交等) +code996 --ignore-author "bot" # 排除所有包含 "bot" 的作者 +code996 --ignore-author "bot|jenkins|github-actions" # 排除多个作者(使用 | 分隔) +code996 --ignore-msg "^Merge" # 排除所有以 "Merge" 开头的提交消息 +code996 --ignore-msg "merge|lint|format" # 排除多个关键词 +code996 -y 2025 --ignore-author "renovate|dependabot" --ignore-msg "^Merge" # 综合过滤 + +# ===== 导出报告(新功能) ===== +code996 -f html # 导出为精美的 HTML 报告 +code996 -f md # 导出为 Markdown 格式 +code996 -f png # 导出为 PNG 图片 +code996 -y 2024 -f html -o # 分析2024年,导出 HTML 并在浏览器打开 +code996 trend -f html # 趋势分析导出为 HTML +code996 --self -f md -o # 分析自己的数据,导出 Markdown 并打开浏览器 +``` -# 查询整个仓库历史 -code996 --all-time +**常见排除场景**: -# 仅分析当前用户的提交记录 -code996 --self -code996 --self -y 2025 # 分析自己在2025年的提交 -code996 trend --self # 查看自己的趋势分析 +```bash +# 排除所有 CI/CD 机器人 +--ignore-author "bot|jenkins|github-actions|gitlab-ci|circleci|travis" + +# 排除依赖更新机器人 +--ignore-author "renovate|dependabot|greenkeeper" + +# 排除合并和格式化提交 +--ignore-msg "^Merge|^merge|lint|format|prettier" + +# 排除自动生成的提交 +--ignore-msg "^chore|^build|^ci|auto" ``` ## 它怎样工作 @@ -100,11 +198,15 @@ Git 仓库 → git log 采集 → 日级首提 + 小时分布 → 分位数推 ### 关键算法 -1. **时间分布分析**:按小时、按星期统计提交数量,绘制 24 小时热力条形图 +1. **时间分布分析**: + - 数据采集:按分钟级别采集提交时间,自动聚合为48个半小时点 + - 算法处理:自动聚合为24小时用于工作时间识别和996指数计算 + - 展示模式:默认按小时展示(24点),可选半小时模式(48点) 2. **工作时间识别**:使用最近样本的 10%-20% 分位估算上班时间区间,并结合晚间提交拐点推算下班时间 3. **996 指数计算**:依据加班比例构建指数,并输出中文描述 -4. **数据验证**:检验统计数据是否与总提交数一致,避免缺失导致的偏差 -5. **算法优势**:新版本采用分位数与拐点估算,能更智能地排除深夜零星提交的干扰,精准定位真实的工作时间窗口 +4. **跨时区协作检测**:通过时区离散度和"睡眠时段"提交比例识别跨时区项目(阈值:非主导时区 >1%),并提供时区过滤建议 +5. **数据验证**:检验统计数据是否与总提交数一致,避免缺失导致的偏差 +6. **算法优势**:新版本采用分位数与拐点估算,能更智能地排除深夜零星提交的干扰,精准定位真实的工作时间窗口 ## 使用提示 diff --git a/package.json b/package.json index 22bd2e7..f638e40 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,11 @@ "typescript": "^5.5.2" }, "dependencies": { + "@resvg/resvg-js": "^2.6.2", "chalk": "^4.1.2", "cli-table3": "^0.6.3", "commander": "^11.1.0", + "open-web-browser": "^0.1.1", "ora": "^5.4.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a450ab..ac4576e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@resvg/resvg-js': + specifier: ^2.6.2 + version: 2.6.2 chalk: specifier: ^4.1.2 version: 4.1.2 @@ -17,6 +20,9 @@ importers: commander: specifier: ^11.1.0 version: 11.1.0 + open-web-browser: + specifier: ^0.1.1 + version: 0.1.1 ora: specifier: ^5.4.1 version: 5.4.1 @@ -314,6 +320,82 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@resvg/resvg-js-android-arm-eabi@2.6.2': + resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@resvg/resvg-js-android-arm64@2.6.2': + resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@resvg/resvg-js-darwin-arm64@2.6.2': + resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@resvg/resvg-js-darwin-x64@2.6.2': + resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js@2.6.2': + resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} + engines: {node: '>= 10'} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -604,6 +686,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -620,6 +705,10 @@ packages: engines: {node: '>=4'} hasBin: true + execa@4.1.0: + resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} + engines: {node: '>=10'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -669,6 +758,10 @@ packages: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -696,6 +789,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + human-signals@1.1.1: + resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} + engines: {node: '>=8.12.0'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -726,6 +823,11 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -750,6 +852,10 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1008,6 +1114,13 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + open-web-browser@0.1.1: + resolution: {integrity: sha512-2riMr1i+v1xpVz61I2MOgtWFU9/bbpmA9EDKiJgfFE5BDLq2K0vvwg0mzas+ueK7F6uOYQ2t9tk8dzHzhjPM3A==} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + ora@5.4.1: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} @@ -1075,6 +1188,9 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -1728,6 +1844,57 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@resvg/resvg-js-android-arm-eabi@2.6.2': + optional: true + + '@resvg/resvg-js-android-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-x64@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js@2.6.2': + optionalDependencies: + '@resvg/resvg-js-android-arm-eabi': 2.6.2 + '@resvg/resvg-js-android-arm64': 2.6.2 + '@resvg/resvg-js-darwin-arm64': 2.6.2 + '@resvg/resvg-js-darwin-x64': 2.6.2 + '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 + '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 + '@resvg/resvg-js-linux-arm64-musl': 2.6.2 + '@resvg/resvg-js-linux-x64-gnu': 2.6.2 + '@resvg/resvg-js-linux-x64-musl': 2.6.2 + '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 + '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 + '@resvg/resvg-js-win32-x64-msvc': 2.6.2 + '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.1': @@ -2028,6 +2195,10 @@ snapshots: emoji-regex@8.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -2038,6 +2209,18 @@ snapshots: esprima@4.0.1: {} + execa@4.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + human-signals: 1.1.1 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -2088,6 +2271,10 @@ snapshots: get-package-type@0.1.0: {} + get-stream@5.2.0: + dependencies: + pump: 3.0.3 + get-stream@6.0.1: {} glob@7.2.3: @@ -2118,6 +2305,8 @@ snapshots: html-escaper@2.0.2: {} + human-signals@1.1.1: {} + human-signals@2.1.0: {} ieee754@1.2.1: {} @@ -2142,6 +2331,8 @@ snapshots: dependencies: hasown: 2.0.2 + is-docker@2.2.1: {} + is-fullwidth-code-point@3.0.0: {} is-generator-fn@2.1.0: {} @@ -2154,6 +2345,10 @@ snapshots: is-unicode-supported@0.1.0: {} + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -2589,6 +2784,17 @@ snapshots: dependencies: mimic-fn: 2.1.0 + open-web-browser@0.1.1: + dependencies: + chalk: 4.1.2 + execa: 4.1.0 + open: 7.4.2 + + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + ora@5.4.1: dependencies: bl: 4.1.0 @@ -2653,6 +2859,11 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + pure-rand@6.1.0: {} react-is@18.3.1: {} diff --git a/src/__tests__/exporter.test.ts b/src/__tests__/exporter.test.ts new file mode 100644 index 0000000..27ba2a8 --- /dev/null +++ b/src/__tests__/exporter.test.ts @@ -0,0 +1,302 @@ +import fs from 'fs' +import path from 'path' +import { exportReport } from '../cli/commands/report/exporter' +import { Result996, ParsedGitData, GitLogData } from '../types/git-types' +import { AnalyzeOptions } from '../cli' + +describe('Report Exporter', () => { + const mockResult: Result996 = { + index996: 85.5, + index996Str: '很差,接近996的程度', + overTimeRadio: 30.5, + } + + const mockParsedData: ParsedGitData = { + hourData: [ + { time: '9', count: 5 }, + { time: '10', count: 10 }, + { time: '14', count: 8 }, + ], + dayData: [ + { time: '1', count: 15 }, + { time: '2', count: 20 }, + { time: '3', count: 12 }, + ], + totalCommits: 100, + workHourPl: [ + { time: '工作', count: 70 }, + { time: '加班', count: 30 }, + ], + workWeekPl: [ + { time: '工作日', count: 75 }, + { time: '周末', count: 25 }, + ], + detectedWorkTime: { + startHour: 9, + endHour: 18, + isReliable: true, + sampleCount: 50, + detectionMethod: 'quantile-window', + confidence: 85, + }, + weekendOvertime: { + saturdayDays: 10, + sundayDays: 5, + casualFixDays: 8, + realOvertimeDays: 7, + }, + lateNightAnalysis: { + evening: 5, + lateNight: 3, + midnight: 2, + dawn: 1, + midnightDays: 3, + totalWorkDays: 50, + midnightRate: 6, + totalWeeks: 12, + totalMonths: 3, + }, + } + + const mockRawData: GitLogData = { + byDay: [ + { time: '1', count: 15 }, + { time: '2', count: 20 }, + ], + byHour: [ + { time: '9', count: 5 }, + { time: '10', count: 10 }, + ], + totalCommits: 100, + } + + const mockOptions: AnalyzeOptions = { + since: '2024-01-01', + until: '2024-12-31', + } + + const testOutputDir = path.join(process.cwd(), 'test-output') + + beforeAll(() => { + if (!fs.existsSync(testOutputDir)) { + fs.mkdirSync(testOutputDir, { recursive: true }) + } + }) + + afterAll(() => { + if (fs.existsSync(testOutputDir)) { + fs.rmSync(testOutputDir, { recursive: true, force: true }) + } + }) + + beforeEach(() => { + // 切换到测试输出目录 + process.chdir(testOutputDir) + }) + + describe('导出为文本格式', () => { + it('应该生成 report.txt 文件', async () => { + await exportReport('txt', { + result: mockResult, + parsedData: mockParsedData, + rawData: mockRawData, + options: mockOptions, + timeRange: { + since: '2024-01-01', + until: '2024-12-31', + }, + }) + + const filePath = path.join(testOutputDir, 'report.txt') + expect(fs.existsSync(filePath)).toBe(true) + + const content = fs.readFileSync(filePath, 'utf-8') + expect(content).toContain('CODE996') + expect(content).toContain('85.5') + expect(content).toContain('30.5%') + }) + + it('文本内容应包含关键信息', async () => { + await exportReport('txt', { + result: mockResult, + parsedData: mockParsedData, + rawData: mockRawData, + options: mockOptions, + timeRange: {}, + }) + + const content = fs.readFileSync(path.join(testOutputDir, 'report.txt'), 'utf-8') + expect(content).toContain('996指数') + expect(content).toContain('加班比例') + expect(content).toContain('工作分布') + expect(content).toContain('高频提交') + }) + }) + + describe('导出为 Markdown 格式', () => { + it('应该生成 report.md 文件', async () => { + await exportReport('md', { + result: mockResult, + parsedData: mockParsedData, + rawData: mockRawData, + options: mockOptions, + timeRange: { + since: '2024-01-01', + until: '2024-12-31', + }, + }) + + const filePath = path.join(testOutputDir, 'report.md') + expect(fs.existsSync(filePath)).toBe(true) + + const content = fs.readFileSync(filePath, 'utf-8') + expect(content).toContain('# 📊 CODE996') + expect(content).toContain('| 指标 | 数值 |') + expect(content).toContain('**85.5**') + }) + + it('Markdown 应包含表格', async () => { + await exportReport('markdown', { + result: mockResult, + parsedData: mockParsedData, + rawData: mockRawData, + options: mockOptions, + timeRange: {}, + }) + + const content = fs.readFileSync(path.join(testOutputDir, 'report.md'), 'utf-8') + expect(content).toContain('|') + expect(content).toContain('---') + }) + }) + + describe('导出为 HTML 格式', () => { + it('应该生成 report.html 文件', async () => { + await exportReport('html', { + result: mockResult, + parsedData: mockParsedData, + rawData: mockRawData, + options: mockOptions, + timeRange: { + since: '2024-01-01', + until: '2024-12-31', + }, + }) + + const filePath = path.join(testOutputDir, 'report.html') + expect(fs.existsSync(filePath)).toBe(true) + + const content = fs.readFileSync(filePath, 'utf-8') + expect(content).toContain('') + expect(content).toContain('CODE996') + expect(content).toContain('85.5') + }) + + it('HTML 应包含样式', async () => { + await exportReport('html', { + result: mockResult, + parsedData: mockParsedData, + rawData: mockRawData, + options: mockOptions, + timeRange: {}, + }) + + const content = fs.readFileSync(path.join(testOutputDir, 'report.html'), 'utf-8') + expect(content).toContain(' + + +
+
+

📊 CODE996 分析报告

+
+ 📅 ${ctx.generatedAt} + ⏰ ${ctx.rangeText} +
+
+ +
+
${ctx.indexValue}
+
996 指数
+
${escapeHtml(ctx.indexText)}
+
+ +
+
+ 📈 +
加班比例
+
${ctx.overtimeText}
+
+
+ 📝 +
总提交数
+
${ctx.totalCommits} 次
+
+
+ 💼 +
工作日提交
+
${ctx.workdayCommits} 次
+
+
+ 🎯 +
周末提交
+
${ctx.weekendCommits} 次
+
+
+ +
工作时间
+
${escapeHtml(ctx.workTime)}
+
+
+ 🌙 +
深夜加班
+
${escapeHtml(ctx.lateNightText)}
+
+
+ +
+

🔥 高频提交时段

+ ${hourList.map((item, i) => ` +
+
${i + 1}
+ ${escapeHtml(item)} +
+ `).join('')} +
+ +
+

📆 高频提交星期

+ ${weekdayList.map((item, i) => ` +
+
${i + 1}
+ ${escapeHtml(item)} +
+ `).join('')} +
+ + +
+ +` +} + +/** 构造 SVG 报告,便于后续转换 PNG */ +function buildSvgReport(ctx: ReportContext): string { + const width = 1200 + const height = 800 + + const indexValue = parseFloat(ctx.indexValue) + const getIndexColor = () => { + if (indexValue < 48) return '#10b981' + if (indexValue < 63) return '#34d399' + if (indexValue < 85) return '#fbbf24' + if (indexValue < 100) return '#fb923c' + if (indexValue < 130) return '#f87171' + if (indexValue < 160) return '#dc2626' + return '#991b1b' + } + + const metrics = [ + { label: '加班比例', value: ctx.overtimeText, icon: '📈' }, + { label: '总提交', value: `${ctx.totalCommits} 次`, icon: '📝' }, + { label: '工作日', value: `${ctx.workdayCommits} 次`, icon: '💼' }, + { label: '周末', value: `${ctx.weekendCommits} 次`, icon: '🎯' }, + ] + + const metricsCards = metrics + .map( + (m, i) => ` + + + ${m.icon} + ${escapeXml(m.label)} + ${escapeXml(m.value)} + + ` + ) + .join('') + + const infoLines = [ + { label: '⏰ 工作时间', value: truncate(ctx.workTime, 35) }, + { label: '🌙 深夜加班', value: truncate(ctx.lateNightText, 35) }, + { label: '🔥 高频时段', value: truncate(ctx.topHours[0] || '暂无', 35) }, + { label: '📆 高频星期', value: truncate(ctx.topWeekdays[0] || '暂无', 35) }, + ] + + const infoSvg = infoLines + .map( + (line, i) => ` + + + ${escapeXml(line.label)} + ${escapeXml(line.value)} + + ` + ) + .join('') + + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + 📊 CODE996 + 工作强度分析报告 + 📅 ${escapeXml(ctx.generatedAt)} + ⏰ ${escapeXml(truncate(ctx.rangeText, 60))} + + + + + ${ctx.indexValue} + 996指数 + ${escapeXml(truncate(ctx.indexText, 16))} + + + + ${metricsCards} + + + ${infoSvg} + + + + 💡 提示:996指数仅供参考,请结合团队实际情况辅助判断 + 🔒 隐私:所有分析均在本地进行,不会上传任何数据 + +` +} + +/** 渲染 PNG,使用 resvg 将 SVG 转成位图 */ +async function renderPng(svg: string): Promise { + const { Resvg } = await import('@resvg/resvg-js') + const resvg = new Resvg(svg, { + fitTo: { mode: 'width', value: 920 }, + }) + const pngData = resvg.render() + return pngData.asPng() +} + +/** 组装总结数据,便于多格式复用 */ +function buildReportContext({ result, parsedData, rawData, options, timeRange }: ExportPayload): ReportContext { + const rangeText = buildRangeText(options, timeRange) + const workTime = buildWorkTimeText(parsedData) + const weekendOvertimeText = buildWeekendOvertimeText(parsedData) + const lateNightText = buildLateNightText(parsedData) + const topHours = pickTopHours(parsedData) + const topWeekdays = pickTopWeekdays(parsedData) + + const generatedAt = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }) + const workdayCommits = parsedData.workWeekPl?.[0]?.count ?? 0 + const weekendCommits = parsedData.workWeekPl?.[1]?.count ?? 0 + + return { + rangeText, + indexText: result.index996Str, + indexValue: result.index996.toFixed(1), + overtimeText: `${result.overTimeRadio.toFixed(1)}%`, + totalCommits: rawData.totalCommits, + workdayCommits, + weekendCommits, + workTime, + weekendOvertimeText, + lateNightText, + topHours, + topWeekdays, + generatedAt, + } +} + +/** 构造时间范围文本 */ +function buildRangeText( + options: AnalyzeOptions, + timeRange: ExportPayload['timeRange'] +): string { + if (options.since && options.until) { + return `${options.since} 至 ${options.until}` + } + + if (options.since) { + return `自 ${options.since} 起` + } + + if (options.until) { + return `截至 ${options.until}` + } + + if (options.allTime) { + return '所有时间' + } + + if (timeRange.since && timeRange.until) { + if (timeRange.mode === 'auto-last-commit') { + return `${timeRange.since} 至 ${timeRange.until}(按最后一次提交回溯365天)` + } + + if (timeRange.mode === 'fallback') { + return `${timeRange.since} 至 ${timeRange.until}(按当前日期回溯365天)` + } + + return `${timeRange.since} 至 ${timeRange.until}` + } + + return '最近一年' +} + +/** 构造工作时间描述 */ +function buildWorkTimeText(parsedData: ParsedGitData): string { + const start = formatStartClock(parsedData.detectedWorkTime) + const end = formatEndClock(parsedData.detectedWorkTime) + + if (!parsedData.detectedWorkTime) { + return '暂无可靠的工作时间推测' + } + + return `${start} - ${end}` +} + +/** 周末加班概览 */ +function buildWeekendOvertimeText(parsedData: ParsedGitData): string { + const data = parsedData.weekendOvertime + + if (!data) { + return '暂无周末加班数据' + } + + const total = data.saturdayDays + data.sundayDays + if (total === 0) { + return '无周末提交记录' + } + + return `周六${data.saturdayDays}天/周日${data.sundayDays}天,真正加班${data.realOvertimeDays}天,临时修复${data.casualFixDays}天` +} + +/** 深夜加班概览 */ +function buildLateNightText(parsedData: ParsedGitData): string { + const analysis = parsedData.lateNightAnalysis + if (!analysis) { + return '暂无深夜加班数据' + } + + if (analysis.midnightDays === 0) { + return '深夜提交较少或不存在' + } + + const rate = `${analysis.midnightRate.toFixed(1)}%` + return `深夜/凌晨加班 ${analysis.midnightDays} 天,占工作日 ${rate}` +} + +/** 取高频小时段 */ +function pickTopHours(parsedData: ParsedGitData): string[] { + const sorted = [...parsedData.hourData].filter((item) => item.count > 0).sort((a, b) => b.count - a.count) + return sorted.slice(0, 3).map((item) => `${item.time.padStart(2, '0')} 点 (${item.count} 次)`) +} + +/** 取高频星期 */ +function pickTopWeekdays(parsedData: ParsedGitData): string[] { + const weekNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] + const sorted = [...parsedData.dayData].filter((item) => item.count > 0).sort((a, b) => b.count - a.count) + return sorted.slice(0, 3).map((item) => { + const index = Math.max(0, Math.min(6, parseInt(item.time, 10) - 1)) + const name = weekNames[index] || '未知' + return `${name} (${item.count} 次)` + }) +} + +/** 规范化输出路径 */ +function resolveOutputPath(format: ReportFormat): string { + const cwd = process.cwd() + + switch (format) { + case 'html': + return path.resolve(cwd, 'report.html') + case 'md': + return path.resolve(cwd, 'report.md') + case 'svg': + return path.resolve(cwd, 'report.svg') + case 'png': + return path.resolve(cwd, 'report.png') + default: + return path.resolve(cwd, 'report.txt') + } +} + +/** 简易 HTML/XML 转义 */ +function escapeHtml(input: string): string { + return input.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +/** SVG 文本转义 */ +function escapeXml(input: string): string { + return escapeHtml(input).replace(/'/g, ''') +} + +/** 控制字符串长度,避免 SVG 溢出 */ +function truncate(input: string, maxLength: number): string { + if (input.length <= maxLength) { + return input + } + return `${input.slice(0, maxLength - 1)}…` +} diff --git a/src/cli/index.ts b/src/cli/index.ts index b7a8c7e..13d8145 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -12,6 +12,8 @@ export interface AnalyzeOptions { allTime?: boolean year?: string self?: boolean + format?: 'txt' | 'md' | 'html' | 'svg' | 'png' + open?: boolean } export class CLIManager { @@ -48,6 +50,8 @@ export class CLIManager { .option('-y, --year ', '指定年份或年份范围 (例如: 2025 或 2023-2025)') .option('--all-time', '查询所有时间的数据(默认为最近一年)') .option('--self', '仅统计当前 Git 用户的提交') + .option('-f, --format ', '输出格式: txt | md | html | svg | png (默认 txt)', 'txt') + .option('-o, --open', '生成 URL 后自动在浏览器中打开') .action(async (repoPath: string | undefined, options: AnalyzeOptions, command: Command) => { const processedArgs = typeof repoPath === 'string' ? 1 : 0 const extraArgs = (command.args ?? []).slice(processedArgs) @@ -74,6 +78,8 @@ export class CLIManager { .option('-y, --year ', '指定年份或年份范围 (例如: 2025 或 2023-2025)') .option('--all-time', '查询所有时间的数据') .option('--self', '仅统计当前 Git 用户的提交') + .option('-f, --format ', '输出格式: txt | md | html | svg | png (默认 txt)', 'txt') + .option('-o, --open', '生成 URL 后自动在浏览器中打开') .argument('[repoPath]', 'Git 仓库根目录路径(默认当前目录)') .action(async (repoPath: string | undefined, options: AnalyzeOptions, command: Command) => { const processedArgs = typeof repoPath === 'string' ? 1 : 0 @@ -145,6 +151,8 @@ export class CLIManager { since: options.since ?? globalOpts.since, until: options.until ?? globalOpts.until, year: options.year ?? globalOpts.year, + format: options.format ?? globalOpts.format ?? 'txt', + open: options.open ?? globalOpts.open, } } @@ -250,6 +258,11 @@ ${chalk.bold('示例:')} code996 trend -y 2024 # 分析2024年各月趋势 code996 trend --all-time # 分析所有时间的月度趋势 + ${chalk.gray('# 导出报告')} + code996 -f html # 导出为 HTML 报告 + code996 -f md -o # 导出为 Markdown 并打开 + code996 trend -f png # 趋势分析导出为 PNG 图片 + ${chalk.bold('更多详情请访问:')} https://github.com/code996/code996 `) } diff --git a/src/types/open-web-browser.d.ts b/src/types/open-web-browser.d.ts new file mode 100644 index 0000000..e953e7b --- /dev/null +++ b/src/types/open-web-browser.d.ts @@ -0,0 +1,4 @@ +declare module 'open-web-browser' { + function openBrowser(url: string): Promise + export = openBrowser +} diff --git a/src/utils/url-generator.ts b/src/utils/url-generator.ts new file mode 100644 index 0000000..5372927 --- /dev/null +++ b/src/utils/url-generator.ts @@ -0,0 +1,49 @@ +import chalk from 'chalk' +import openBrowser from 'open-web-browser' +import { ParsedGitData, GitLogData } from '../types/git-types' + +interface UrlData { + timeRange: { + since?: string + until?: string + } + rawData: GitLogData + format?: 'txt' | 'md' | 'html' | 'svg' | 'png' +} + +export function generateVercelUrl(data: UrlData): string { + const { timeRange, rawData, format = 'txt' } = data + + const byDay = rawData.byDay.map((item) => `${item.count}_${item.time}`).join(',') + const byHour = rawData.byHour.map((item) => `${item.count}_${item.time.padStart(2, '0')}`).join(',') + + const since = timeRange.since || '' + const until = timeRange.until || '' + + const result = `${since}_${until}&week=${byDay}&hour=${byHour}` + + const baseUrl = 'https://code996.vercel.app/#/result' + const queryParams = new URLSearchParams({ + time: result, + format: format, + }) + + return `${baseUrl}?${queryParams.toString()}` +} + +export async function openUrlInBrowser(url: string): Promise { + try { + await openBrowser(url) + console.log(chalk.green('✓ 已在浏览器中打开')) + } catch (error) { + console.error(chalk.yellow('⚠️ 无法自动打开浏览器,请手动复制链接访问')) + } +} + +export function printVercelUrl(url: string): void { + console.log() + console.log(chalk.blue('🔗 可视化分析链接:')) + console.log(chalk.cyan(url)) + console.log() + console.log(chalk.gray('提示: 复制以上链接到浏览器查看可视化报告')) +}