pan-stack-cli 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +572 -23
  2. package/bin/pan.js +206 -20
  3. package/lib/ai-skills.js +829 -0
  4. package/lib/create.js +22 -4
  5. package/lib/discover.js +69 -2
  6. package/lib/git/git-checkout.js +395 -0
  7. package/lib/git/git-clone.js +163 -28
  8. package/lib/git/git-fetch-all.js +100 -33
  9. package/lib/git/git-grv.js +68 -37
  10. package/lib/git/git-gst.js +97 -26
  11. package/lib/git/git-mr.js +546 -0
  12. package/lib/git/git-pull.js +104 -43
  13. package/lib/git/git-push.js +150 -41
  14. package/lib/git/git-sync-main.js +334 -0
  15. package/lib/git/helpers/bulk-report.js +202 -0
  16. package/lib/git/helpers/cli.js +41 -5
  17. package/lib/git/helpers/colors.js +19 -0
  18. package/lib/git/helpers/git.js +74 -0
  19. package/lib/git/helpers/manifest.js +93 -0
  20. package/lib/git/helpers/repo-select.js +416 -0
  21. package/lib/git/helpers/repo-summary.js +161 -0
  22. package/lib/i18n.js +61 -0
  23. package/lib/inquirer-theme.js +20 -0
  24. package/lib/language.js +121 -0
  25. package/lib/languages.js +14 -0
  26. package/lib/manifest.js +115 -14
  27. package/lib/menu-prompt.js +201 -0
  28. package/lib/menu.js +134 -0
  29. package/lib/messages.js +647 -0
  30. package/lib/run-command.js +173 -52
  31. package/lib/select.js +0 -0
  32. package/lib/serve.js +75 -0
  33. package/lib/skill-registry-client.js +173 -0
  34. package/lib/stats/aggregate.js +51 -0
  35. package/lib/stats/identity.js +20 -0
  36. package/lib/stats/index.js +189 -0
  37. package/lib/stats/parse.js +46 -0
  38. package/lib/stats/range.js +38 -0
  39. package/lib/stats/report.js +264 -0
  40. package/lib/stats/review.js +157 -0
  41. package/lib/stats/scan.js +92 -0
  42. package/lib/stats/serve.js +25 -0
  43. package/lib/ui/api.js +202 -0
  44. package/lib/ui/clone.js +115 -0
  45. package/lib/ui/config.js +182 -0
  46. package/lib/ui/diff.js +128 -0
  47. package/lib/ui/export-gate.js +79 -0
  48. package/lib/ui/gen-react-mobile-code.js +6 -0
  49. package/lib/ui/gen-react-web-code.js +75 -0
  50. package/lib/ui/index.js +89 -0
  51. package/lib/ui/init.js +252 -0
  52. package/lib/ui/login.js +128 -0
  53. package/lib/ui/logout.js +22 -0
  54. package/lib/ui/palettes.js +103 -0
  55. package/lib/ui/published-html.js +24 -0
  56. package/lib/ui/sync.js +137 -0
  57. package/lib/ui/workspace.js +213 -0
  58. package/package.json +10 -3
  59. package/templates/workspace/.claude/skills/writing-guidelines/SKILL.md +0 -48
  60. package/templates/workspace/.codex/skills/writing-guidelines/SKILL.md +0 -48
  61. package/templates/workspace/.cursor/skills/writing-guidelines/SKILL.md +0 -48
  62. package/templates/workspace/.pan.yaml +0 -25
  63. package/templates/workspace/.project.yaml +0 -15
  64. package/templates/workspace/README.md +0 -35
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+ const { findNearestWorkspace } = require("../discover");
7
+ const { reposFromWorkspace, discoverGitRepos, scanAll } = require("./scan");
8
+ const { resolveRange } = require("./range");
9
+ const { loadAliasMap, canonicalEmail } = require("./identity");
10
+ const { aggregate } = require("./aggregate");
11
+ const { buildReport } = require("./report");
12
+ const { serveHtml } = require("./serve");
13
+ const { buildReviewMarkdown, writeReview } = require("./review");
14
+
15
+ function parseArgs(argv) {
16
+ const o = { _: [], open: true, diff: true };
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const a = argv[i];
19
+ if (a === "--since") o.since = argv[++i];
20
+ else if (a === "--from") o.from = argv[++i];
21
+ else if (a === "--to") o.to = argv[++i];
22
+ else if (a === "--member" || a === "-m") o.member = argv[++i];
23
+ else if (a === "--project" || a === "-p") o.project = argv[++i];
24
+ else if (a === "--port") o.port = parseInt(argv[++i], 10);
25
+ else if (a === "--out" || a === "-o") o.out = argv[++i];
26
+ else if (a === "--no-open") o.open = false;
27
+ else if (a === "--no-diff") o.diff = false;
28
+ else if (a === "--head-only") o.headOnly = true;
29
+ else if (a === "--max-diff-lines") o.maxDiffLines = parseInt(argv[++i], 10);
30
+ else if (a.startsWith("--since=")) o.since = a.slice(8);
31
+ else o._.push(a);
32
+ }
33
+ return o;
34
+ }
35
+
36
+ function dataDir() {
37
+ const dir = path.join(os.homedir(), ".pan-code-graph");
38
+ fs.mkdirSync(dir, { recursive: true });
39
+ return dir;
40
+ }
41
+
42
+ /**
43
+ * Nguồn repo để thống kê: ưu tiên .project.yaml; nếu không có thì fallback quét
44
+ * git quanh pwd (thư mục cha + tối đa 2 cấp).
45
+ * @returns {{repos:Array<{name,dir}>, source:string}}
46
+ */
47
+ function resolveRepos() {
48
+ const ws = findNearestWorkspace();
49
+ if (ws) return { repos: reposFromWorkspace(ws), source: `.project.yaml (${ws.project})` };
50
+
51
+ const repos = discoverGitRepos(process.cwd());
52
+ if (!repos.length) {
53
+ throw new Error(
54
+ `Không tìm thấy pan-stack .project.yaml, cũng không thấy git repo nào từ ${process.cwd()} (đã quét pwd + 2 cấp con).`
55
+ );
56
+ }
57
+ console.log(`ℹ️ Không tìm thấy pan-stack .project.yaml — fallback quét git từ ${process.cwd()} (pwd + 2 cấp con).`);
58
+ return { repos, source: "fallback (quét từ pwd)" };
59
+ }
60
+
61
+ function printHelp() {
62
+ console.log(`pan stats — thống kê đóng góp Git từ .project.yaml
63
+ (không có .project.yaml → fallback quét git từ pwd xuống 2 cấp con)
64
+
65
+ lệnh:
66
+ pan stats [report] Quét & mở báo cáo HTML (mặc định)
67
+ pan stats people Liệt kê thành viên tham gia
68
+ pan stats review Xuất Markdown 1 thành viên × 1 dự án cho AI review
69
+
70
+ lọc thời gian (dùng chung):
71
+ --since <n> Preset ngày: 1 2 3 4 5 7 14 30 (30 = 1 tháng)
72
+ --from <YYYY-MM-DD> --to <YYYY-MM-DD> Khoảng ngày tuỳ chọn
73
+
74
+ options:
75
+ -m, --member <email|tên> Lọc theo thành viên (review: bắt buộc)
76
+ -p, --project <tên repo> Lọc theo dự án (review: bắt buộc)
77
+ --port <n> Cổng serve (mặc định 4321)
78
+ --no-open Không tự mở trình duyệt
79
+ -o, --out <file> Đường dẫn file xuất
80
+ --no-diff (review) chỉ tóm tắt, không kèm diff
81
+ --max-diff-lines <n> (review) giới hạn dòng diff (mặc định 6000)
82
+ --head-only Chỉ quét HEAD hiện tại (mặc định: --all — mọi nhánh
83
+ local + remote-tracking + tags). Nếu commit ở remote
84
+ chưa thấy, chạy 'pan fetch' trước.
85
+
86
+ ví dụ:
87
+ pan stats --since 7
88
+ pan stats report --from 2026-07-01 --to 2026-07-22
89
+ pan stats people
90
+ pan stats review -m an@fpt.com -p csdl-cc-backend --since 7 -o review.md
91
+ `);
92
+ }
93
+
94
+ async function runStats(argv) {
95
+ const o = parseArgs(argv);
96
+ const sub = ["report", "people", "review"].includes(o._[0]) ? o._.shift() : "report";
97
+
98
+ if (o._[0] === "help" || o.help) return printHelp();
99
+
100
+ if (sub === "review") return runReview(o);
101
+ if (sub === "people") return runPeople(o);
102
+ return runReport(o);
103
+ }
104
+
105
+ function loadAndScan(o) {
106
+ const { repos } = resolveRepos();
107
+ const range = resolveRange(o);
108
+ const allBranches = !o.headOnly;
109
+ const scope = allBranches ? "mọi nhánh" : "chỉ HEAD";
110
+ console.log(`🔍 Quét ${repos.length} repo · ${scope} · ${range.label}:`);
111
+ const { commits, repoStatus } = scanAll(repos, range, { allBranches });
112
+ const aliasMap = loadAliasMap(path.join(dataDir(), "alias-map.json"));
113
+ return { repos, range, commits, repoStatus, aliasMap };
114
+ }
115
+
116
+ function runReport(o) {
117
+ // Quét TOÀN BỘ lịch sử để nhúng commit thô → bộ lọc thời gian chạy phía client.
118
+ const { repos } = resolveRepos();
119
+ const fullRange = resolveRange({});
120
+ const allBranches = !o.headOnly;
121
+ const scope = allBranches ? "mọi nhánh" : "chỉ HEAD";
122
+ console.log(`🔍 Quét ${repos.length} repo · ${scope} · toàn bộ (lọc thời gian trong report):`);
123
+ const { commits, repoStatus } = scanAll(repos, fullRange, { allBranches });
124
+ const aliasMap = loadAliasMap(path.join(dataDir(), "alias-map.json"));
125
+
126
+ // Commit thô, gọn: e=email chuẩn, n=tên, t=ts(giây), r=repo, a=add, d=del
127
+ const rawCommits = commits.map((c) => ({
128
+ e: canonicalEmail(c.email, aliasMap),
129
+ n: c.name || c.email,
130
+ t: c.ts,
131
+ r: c.repoName,
132
+ a: c.additions,
133
+ d: c.deletions,
134
+ }));
135
+
136
+ const unavailable = repoStatus.filter((r) => !r.available).map((r) => ({ name: r.name, reason: r.reason }));
137
+ const initial = resolveRange(o); // range khởi tạo từ CLI (mặc định: toàn bộ)
138
+ const meta = {
139
+ reposTotal: repoStatus.length,
140
+ reposAvailable: repoStatus.filter((r) => r.available).length,
141
+ unavailable,
142
+ initialSince: o.since != null ? Number(o.since) : null,
143
+ initialFrom: o.from || null,
144
+ initialTo: o.to || null,
145
+ initialMembers: o.member ? [o.member] : [],
146
+ };
147
+
148
+ const html = buildReport(rawCommits, meta);
149
+ const outFile = o.out || path.join(dataDir(), "report.html");
150
+ fs.writeFileSync(outFile, html);
151
+ console.log(`💾 Đã ghi ${outFile} (${rawCommits.length} commit)`);
152
+ if (unavailable.length) console.log(`⚠️ ${unavailable.length} repo không đọc được (đã ghi cảnh báo trong report).`);
153
+ serveHtml(html, { port: o.port || 4321, open: o.open });
154
+ }
155
+
156
+ function runPeople(o) {
157
+ const { commits, aliasMap } = loadAndScan(o);
158
+ const { members } = aggregate(commits, aliasMap);
159
+ console.log(`\n👥 ${members.length} thành viên tham gia:\n`);
160
+ for (const m of members) {
161
+ console.log(` ${String(m.commits).padStart(5)} commit ${m.name} <${m.email}>`);
162
+ }
163
+ }
164
+
165
+ function runReview(o) {
166
+ if (!o.member || !o.project) {
167
+ throw new Error("review cần --member <email|tên> và --project <tên repo>.");
168
+ }
169
+ const { repos } = resolveRepos();
170
+ const repo = repos.find((r) => r.name === o.project);
171
+ if (!repo) throw new Error(`Không có dự án '${o.project}' (repo khả dụng: ${repos.map((r) => r.name).join(", ")}).`);
172
+ if (!fs.existsSync(repo.dir)) throw new Error(`Dự án '${o.project}' chưa clone (${repo.dir}).`);
173
+
174
+ const range = resolveRange(o);
175
+ console.log(`📝 Tạo review: ${o.member} × ${o.project} (${range.label})`);
176
+ const md = buildReviewMarkdown(repo, o.member, range, {
177
+ withDiff: o.diff,
178
+ maxDiffLines: o.maxDiffLines,
179
+ allBranches: !o.headOnly,
180
+ });
181
+
182
+ const safe = `${o.project}_${o.member.replace(/[^a-z0-9]+/gi, "-")}`.toLowerCase();
183
+ const outFile = o.out || path.join(dataDir(), `review-${safe}.md`);
184
+ writeReview(outFile, md);
185
+ console.log(`✅ Đã ghi ${outFile}`);
186
+ console.log(` → Mở file này và nhờ AI review/chấm điểm.`);
187
+ }
188
+
189
+ module.exports = { runStats };
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+
3
+ const US = "\x1f"; // Unit Separator — ranh giới field trong header
4
+ const HEAD = "@@@"; // tiền tố dòng header commit
5
+
6
+ // Header: @@@<hash>US<email>US<name>US<unixDate>US<subject>
7
+ // Theo sau là các dòng numstat: "additions\tdeletions\tpath" (binary = "-").
8
+ const PRETTY_FORMAT = `${HEAD}%H${US}%ae${US}%an${US}%ad${US}%s`;
9
+
10
+ /**
11
+ * Parse output thô của `git log --numstat` thành mảng commit.
12
+ */
13
+ function parseGitLog(raw) {
14
+ const commits = [];
15
+ let cur = null;
16
+
17
+ for (const line of raw.split("\n")) {
18
+ if (line.startsWith(HEAD)) {
19
+ if (cur) commits.push(cur);
20
+ const [hash, email, name, date, subject] = line.slice(HEAD.length).split(US);
21
+ cur = {
22
+ hash,
23
+ email: (email || "").toLowerCase().trim(),
24
+ name: (name || "").trim(),
25
+ ts: Number(date) || 0,
26
+ subject: subject || "",
27
+ additions: 0,
28
+ deletions: 0,
29
+ files: 0,
30
+ };
31
+ continue;
32
+ }
33
+ if (!cur || !line.trim()) continue;
34
+ const parts = line.split("\t");
35
+ if (parts.length < 3) continue;
36
+ const add = parts[0] === "-" ? 0 : parseInt(parts[0], 10) || 0;
37
+ const del = parts[1] === "-" ? 0 : parseInt(parts[1], 10) || 0;
38
+ cur.additions += add;
39
+ cur.deletions += del;
40
+ cur.files += 1;
41
+ }
42
+ if (cur) commits.push(cur);
43
+ return commits;
44
+ }
45
+
46
+ module.exports = { parseGitLog, PRETTY_FORMAT, US, HEAD };
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+
3
+ const PRESET_DAYS = new Set([1, 2, 3, 4, 5, 7, 14, 30]);
4
+
5
+ /**
6
+ * Chuẩn hoá khoảng thời gian từ options.
7
+ * --since <n> (preset ngày) HOẶC --from/--to (YYYY-MM-DD). Không có → toàn bộ.
8
+ * @returns {{from:number,to:number,label:string}} unix giây
9
+ */
10
+ function resolveRange({ since, from, to } = {}) {
11
+ const nowSec = Math.floor(Date.now() / 1000);
12
+
13
+ if (from || to) {
14
+ const fromSec = from ? Math.floor(Date.parse(from + "T00:00:00") / 1000) : -Infinity;
15
+ const toSec = to ? Math.floor(Date.parse(to + "T23:59:59") / 1000) : nowSec;
16
+ const label = `${from || "…"} → ${to || "nay"}`;
17
+ return { from: fromSec, to: toSec, label };
18
+ }
19
+
20
+ if (since != null && since !== "") {
21
+ const n = Number(since);
22
+ if (!Number.isFinite(n) || n <= 0) {
23
+ throw new Error(`--since không hợp lệ: ${since} (dùng 1 2 3 4 5 7 14 30)`);
24
+ }
25
+ const label = n === 30 ? "1 tháng gần đây" : `${n} ngày gần đây`;
26
+ return { from: nowSec - n * 86400, to: nowSec, label };
27
+ }
28
+
29
+ return { from: -Infinity, to: Infinity, label: "Toàn bộ lịch sử" };
30
+ }
31
+
32
+ /** ISO date (giây) cho git --since/--until; -Infinity/Infinity → null. */
33
+ function toGitDate(sec) {
34
+ if (!Number.isFinite(sec)) return null;
35
+ return new Date(sec * 1000).toISOString();
36
+ }
37
+
38
+ module.exports = { resolveRange, toGitDate, PRESET_DAYS };
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Dựng file HTML self-contained (Tailwind CDN, bảng màu FPT).
5
+ * Nhúng commit thô → lọc thời gian + chọn thành viên chạy hoàn toàn phía client.
6
+ * @param {Array} commits [{e,n,t,r,a,d}] (email, name, ts giây, repo, add, del)
7
+ * @param {object} meta { reposTotal, reposAvailable, unavailable[], initial* }
8
+ */
9
+ function buildReport(commits, meta = {}) {
10
+ const payload = JSON.stringify({ commits, meta });
11
+ return `<!doctype html>
12
+ <html lang="vi">
13
+ <head>
14
+ <meta charset="utf-8" />
15
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
16
+ <title>pan stats — Thống kê đóng góp</title>
17
+ <script src="https://cdn.tailwindcss.com"></script>
18
+ <script>
19
+ tailwind.config = { theme: { extend: { colors: {
20
+ fptOrange: '#F37021', fptGreen: '#4EB748', fptBlue: '#0066B3', fptRed: '#E5484D'
21
+ } } } };
22
+ </script>
23
+ </head>
24
+ <body class="bg-slate-50 text-slate-800">
25
+ <header class="bg-gradient-to-r from-fptOrange to-fptBlue text-white">
26
+ <div class="max-w-7xl mx-auto px-6 py-6">
27
+ <h1 class="text-2xl font-bold">Thống kê đóng góp Git — pan stats</h1>
28
+ <p id="meta" class="text-white/90 text-sm mt-1"></p>
29
+
30
+ <div class="mt-4 flex flex-wrap gap-2 items-center">
31
+ <span class="text-white/80 text-sm mr-1">Khoảng:</span>
32
+ <div id="presets" class="flex flex-wrap gap-1"></div>
33
+ <span class="text-white/70 mx-1">·</span>
34
+ <input id="from" type="date" class="px-2 py-1.5 rounded-lg text-slate-800 text-sm outline-none" />
35
+ <span class="text-white/70">→</span>
36
+ <input id="to" type="date" class="px-2 py-1.5 rounded-lg text-slate-800 text-sm outline-none" />
37
+ <button id="applyDates" class="px-3 py-1.5 rounded-lg bg-white/90 text-fptBlue font-semibold text-sm hover:bg-white">Áp dụng</button>
38
+ </div>
39
+
40
+ <div class="mt-3 flex flex-wrap gap-2 items-center">
41
+ <div class="relative">
42
+ <button id="memberBtn" class="px-3 py-2 rounded-lg bg-white text-slate-800 text-sm font-medium min-w-56 text-left flex items-center justify-between gap-2">
43
+ <span id="memberLabel">Tất cả thành viên</span><span class="text-slate-400">▾</span>
44
+ </button>
45
+ <div id="memberMenu" class="hidden absolute z-20 mt-1 w-72 max-h-72 overflow-auto bg-white text-slate-800 rounded-lg shadow-xl border border-slate-200 p-2">
46
+ <div class="flex gap-2 mb-2">
47
+ <button id="mAll" class="text-xs px-2 py-1 rounded bg-slate-100 hover:bg-slate-200">Chọn tất cả</button>
48
+ <button id="mNone" class="text-xs px-2 py-1 rounded bg-slate-100 hover:bg-slate-200">Bỏ chọn</button>
49
+ </div>
50
+ <div id="memberList" class="space-y-1"></div>
51
+ </div>
52
+ </div>
53
+ <button id="toggleAll" class="px-4 py-2 rounded-lg bg-white text-fptBlue font-semibold text-sm hover:bg-white/90">Mở tất cả</button>
54
+ </div>
55
+ </div>
56
+ </header>
57
+
58
+ <main class="max-w-7xl mx-auto px-6 py-8">
59
+ <div id="warnBox" class="hidden mb-6 rounded-xl border border-amber-300 bg-amber-50 text-amber-900 px-4 py-3 text-sm"></div>
60
+
61
+ <section id="kpis" class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6"></section>
62
+
63
+ <section id="cards" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"></section>
64
+
65
+ <h2 class="mt-10 mb-3 text-lg font-bold text-fptBlue">Bảng tổng hợp</h2>
66
+ <div class="overflow-x-auto rounded-xl border border-slate-200 bg-white">
67
+ <table class="w-full text-sm">
68
+ <thead class="bg-slate-100 text-slate-600"><tr>
69
+ <th class="text-left px-4 py-2">Thành viên</th>
70
+ <th class="text-right px-4 py-2">Dự án</th>
71
+ <th class="text-right px-4 py-2">Commit</th>
72
+ <th class="text-right px-4 py-2">Thêm (+)</th>
73
+ <th class="text-right px-4 py-2">Xoá (−)</th>
74
+ </tr></thead>
75
+ <tbody id="summaryBody"></tbody>
76
+ <tfoot class="font-bold bg-slate-50 border-t-2 border-slate-200"><tr id="summaryTotal"></tr></tfoot>
77
+ </table>
78
+ </div>
79
+ </main>
80
+
81
+ <script>
82
+ const PAYLOAD = ${payload};
83
+ const COMMITS = PAYLOAD.commits;
84
+ const META = PAYLOAD.meta;
85
+ const nf = new Intl.NumberFormat('vi-VN');
86
+ const PRESETS = [1,2,3,4,5,7,14,30];
87
+
88
+ let expanded = false;
89
+ let range = { from: -Infinity, to: Infinity, label: 'Toàn bộ lịch sử' };
90
+ let selected = new Set(); // rỗng = tất cả
91
+
92
+ function initials(n){ return (n||'?').trim().split(/\\s+/).map(w=>w[0]).slice(0,2).join('').toUpperCase(); }
93
+
94
+ // Danh sách thành viên (từ toàn bộ commit) để đổ vào dropdown
95
+ const ALL_MEMBERS = (() => {
96
+ const m = new Map();
97
+ for (const c of COMMITS){ if(!m.has(c.e)) m.set(c.e, c.n); else m.set(c.e, c.n); }
98
+ return [...m.entries()].map(([e,n])=>({email:e,name:n})).sort((a,b)=>a.name.localeCompare(b.name));
99
+ })();
100
+
101
+ // Tổng hợp theo range + selected
102
+ function aggregate(){
103
+ const byEmail = new Map();
104
+ const projectSet = new Set();
105
+ for (const c of COMMITS){
106
+ if (c.t < range.from || c.t > range.to) continue;
107
+ if (selected.size && !selected.has(c.e)) continue;
108
+ projectSet.add(c.r);
109
+ if (!byEmail.has(c.e)) byEmail.set(c.e, { email:c.e, name:c.n, commits:0, additions:0, deletions:0, projects:new Map() });
110
+ const m = byEmail.get(c.e);
111
+ m.commits++; m.additions+=c.a; m.deletions+=c.d; if(c.n) m.name=c.n;
112
+ if(!m.projects.has(c.r)) m.projects.set(c.r,{name:c.r,commits:0,additions:0,deletions:0});
113
+ const p=m.projects.get(c.r); p.commits++; p.additions+=c.a; p.deletions+=c.d;
114
+ }
115
+ const members=[...byEmail.values()].map(m=>({...m,projects:[...m.projects.values()].sort((a,b)=>b.commits-a.commits)})).sort((a,b)=>b.commits-a.commits);
116
+ const totals=members.reduce((s,m)=>{s.commits+=m.commits;s.additions+=m.additions;s.deletions+=m.deletions;return s;},{members:members.length,projects:projectSet.size,commits:0,additions:0,deletions:0});
117
+ return {members,totals};
118
+ }
119
+
120
+ function kpi(label, value, sub, color){
121
+ return \`<div class="rounded-xl bg-white border border-slate-200 p-4 shadow-sm">
122
+ <div class="text-xs uppercase tracking-wide text-slate-500">\${label}</div>
123
+ <div class="mt-1 text-2xl font-bold \${color||''} tabular-nums">\${value}</div>
124
+ <div class="text-xs text-slate-400 mt-0.5">\${sub||''}</div>
125
+ </div>\`;
126
+ }
127
+
128
+ function projectRow(p){
129
+ return \`<div class="flex items-center justify-between text-sm py-1 border-b border-slate-100 last:border-0">
130
+ <span class="text-fptBlue font-medium truncate mr-2">\${p.name}</span>
131
+ <span class="shrink-0 tabular-nums text-slate-500">\${nf.format(p.commits)} commit ·
132
+ <span class="text-fptGreen">+\${nf.format(p.additions)}</span>
133
+ <span class="text-fptRed">−\${nf.format(p.deletions)}</span></span></div>\`;
134
+ }
135
+ function card(m){
136
+ return \`<article class="member-card bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
137
+ <header class="flex items-center gap-3 cursor-pointer select-none toggle-card">
138
+ <div class="w-10 h-10 rounded-full bg-fptOrange text-white grid place-items-center font-bold">\${initials(m.name)}</div>
139
+ <div class="min-w-0"><div class="font-semibold truncate">\${m.name}</div><div class="text-xs text-slate-500 truncate">\${m.email}</div></div>
140
+ </header>
141
+ <div class="mt-3 flex gap-4 text-sm"><span><b>\${nf.format(m.commits)}</b> commit</span>
142
+ <span class="text-fptGreen">+\${nf.format(m.additions)}</span>
143
+ <span class="text-fptRed">−\${nf.format(m.deletions)}</span>
144
+ <span class="text-slate-400">\${m.projects.length} dự án</span></div>
145
+ <div class="projects mt-3 \${expanded?'':'hidden'}">\${m.projects.map(projectRow).join('')}</div></article>\`;
146
+ }
147
+ function summaryRow(m){
148
+ return \`<tr class="border-t border-slate-100">
149
+ <td class="px-4 py-2">\${m.name} <span class="text-slate-400 text-xs">\${m.email}</span></td>
150
+ <td class="px-4 py-2 text-right">\${m.projects.length}</td>
151
+ <td class="px-4 py-2 text-right tabular-nums">\${nf.format(m.commits)}</td>
152
+ <td class="px-4 py-2 text-right tabular-nums text-fptGreen">+\${nf.format(m.additions)}</td>
153
+ <td class="px-4 py-2 text-right tabular-nums text-fptRed">−\${nf.format(m.deletions)}</td></tr>\`;
154
+ }
155
+
156
+ function render(){
157
+ const {members,totals}=aggregate();
158
+ const totalMembers = ALL_MEMBERS.length;
159
+ const filtering = selected.size > 0;
160
+ document.getElementById('meta').textContent =
161
+ \`\${range.label} · \${META.reposAvailable}/\${META.reposTotal} dự án đọc được\${filtering ? ' · đang lọc ' + selected.size + '/' + totalMembers + ' thành viên' : ''}\`;
162
+
163
+ document.getElementById('kpis').innerHTML = [
164
+ kpi('Thành viên', nf.format(totals.members), (filtering ? 'lọc ' + selected.size + '/' + totalMembers : 'trên tổng ' + totalMembers), 'text-fptBlue'),
165
+ kpi('Dự án có commit', nf.format(totals.projects), META.reposAvailable + '/' + META.reposTotal + ' đọc được', 'text-fptOrange'),
166
+ kpi('Commit', nf.format(totals.commits), range.label, 'text-slate-800'),
167
+ kpi('Dòng thay đổi', '<span class="text-fptGreen">+' + nf.format(totals.additions) + '</span> <span class="text-slate-300">/</span> <span class="text-fptRed">−' + nf.format(totals.deletions) + '</span>', 'add / del', ''),
168
+ ].join('');
169
+
170
+ document.getElementById('cards').innerHTML = members.length ? members.map(card).join('')
171
+ : '<p class="text-slate-400 col-span-full">Không có dữ liệu trong khoảng/chọn hiện tại.</p>';
172
+ document.getElementById('summaryBody').innerHTML = members.map(summaryRow).join('');
173
+ document.getElementById('summaryTotal').innerHTML =
174
+ \`<td class="px-4 py-2">TỔNG</td><td class="px-4 py-2 text-right">—</td>
175
+ <td class="px-4 py-2 text-right tabular-nums">\${nf.format(totals.commits)}</td>
176
+ <td class="px-4 py-2 text-right tabular-nums text-fptGreen">+\${nf.format(totals.additions)}</td>
177
+ <td class="px-4 py-2 text-right tabular-nums text-fptRed">−\${nf.format(totals.deletions)}</td>\`;
178
+ document.querySelectorAll('.toggle-card').forEach(h=>h.addEventListener('click',()=>h.parentElement.querySelector('.projects').classList.toggle('hidden')));
179
+ }
180
+
181
+ // ── Bộ lọc thời gian ──
182
+ function setPreset(n){
183
+ const now=Math.floor(Date.now()/1000);
184
+ range={from:now-n*86400,to:now,label:(n===30?'1 tháng gần đây':n+' ngày gần đây')};
185
+ document.getElementById('from').value=''; document.getElementById('to').value='';
186
+ markPreset(n); render();
187
+ }
188
+ function setAllTime(){ range={from:-Infinity,to:Infinity,label:'Toàn bộ lịch sử'}; markPreset(null); render(); }
189
+ function setCustom(){
190
+ const f=document.getElementById('from').value, t=document.getElementById('to').value;
191
+ if(!f&&!t){ setAllTime(); return; }
192
+ const from=f?Math.floor(Date.parse(f+'T00:00:00')/1000):-Infinity;
193
+ const to=t?Math.floor(Date.parse(t+'T23:59:59')/1000):Math.floor(Date.now()/1000);
194
+ range={from,to,label:(f||'…')+' → '+(t||'nay')}; markPreset(null); render();
195
+ }
196
+ function markPreset(n){
197
+ document.querySelectorAll('#presets [data-p]').forEach(b=>{
198
+ const on = String(n)===b.dataset.p;
199
+ b.className='px-2.5 py-1.5 rounded-lg text-sm '+(on?'bg-white text-fptBlue font-semibold':'bg-white/20 text-white hover:bg-white/30');
200
+ });
201
+ }
202
+
203
+ // ── Dropdown thành viên (multi-select) ──
204
+ function renderMemberMenu(){
205
+ document.getElementById('memberList').innerHTML = ALL_MEMBERS.map(m=>\`
206
+ <label class="flex items-center gap-2 px-1 py-0.5 rounded hover:bg-slate-50 cursor-pointer text-sm">
207
+ <input type="checkbox" class="m-chk" value="\${m.email}" \${selected.has(m.email)?'checked':''}/>
208
+ <span class="truncate"><b>\${m.name}</b> <span class="text-slate-400 text-xs">\${m.email}</span></span>
209
+ </label>\`).join('');
210
+ document.querySelectorAll('.m-chk').forEach(chk=>chk.addEventListener('change',()=>{
211
+ if(chk.checked) selected.add(chk.value); else selected.delete(chk.value);
212
+ updateMemberLabel(); render();
213
+ }));
214
+ }
215
+ function updateMemberLabel(){
216
+ const el=document.getElementById('memberLabel');
217
+ el.textContent = selected.size===0 ? 'Tất cả thành viên'
218
+ : selected.size===1 ? (ALL_MEMBERS.find(m=>selected.has(m.email))||{}).name || '1 thành viên'
219
+ : selected.size+' thành viên';
220
+ }
221
+
222
+ // init
223
+ function init(){
224
+ document.getElementById('presets').innerHTML =
225
+ '<button data-p="all" class="px-2.5 py-1.5 rounded-lg text-sm bg-white text-fptBlue font-semibold">Tất cả</button>' +
226
+ PRESETS.map(n=>\`<button data-p="\${n}" class="px-2.5 py-1.5 rounded-lg text-sm bg-white/20 text-white hover:bg-white/30">\${n===30?'1 tháng':n+'n'}</button>\`).join('');
227
+ document.querySelector('#presets [data-p="all"]').addEventListener('click',setAllTime);
228
+ PRESETS.forEach(n=>document.querySelector('#presets [data-p="'+n+'"]').addEventListener('click',()=>setPreset(n)));
229
+ document.getElementById('applyDates').addEventListener('click',setCustom);
230
+
231
+ const un=META.unavailable||[];
232
+ if(un.length){
233
+ const w=document.getElementById('warnBox'); w.classList.remove('hidden');
234
+ w.innerHTML='<b>⚠️ '+un.length+' dự án không thống kê được</b> (chưa clone / không đọc được — pan-stack là vỏ bọc, cần đủ quyền):<ul class="list-disc ml-5 mt-1">'+un.map(u=>'<li><b>'+u.name+'</b> — '+u.reason+'</li>').join('')+'</ul>';
235
+ }
236
+
237
+ document.getElementById('memberBtn').addEventListener('click',()=>document.getElementById('memberMenu').classList.toggle('hidden'));
238
+ document.addEventListener('click',(e)=>{ if(!e.target.closest('#memberBtn')&&!e.target.closest('#memberMenu')) document.getElementById('memberMenu').classList.add('hidden'); });
239
+ document.getElementById('mAll').addEventListener('click',()=>{selected=new Set(ALL_MEMBERS.map(m=>m.email));renderMemberMenu();updateMemberLabel();render();});
240
+ document.getElementById('mNone').addEventListener('click',()=>{selected=new Set();renderMemberMenu();updateMemberLabel();render();});
241
+
242
+ document.getElementById('toggleAll').addEventListener('click',(e)=>{
243
+ expanded=!expanded;
244
+ document.querySelectorAll('.projects').forEach(p=>p.classList.toggle('hidden',!expanded));
245
+ e.target.textContent=expanded?'Thu gọn tất cả':'Mở tất cả';
246
+ });
247
+
248
+ // giá trị khởi tạo từ CLI
249
+ if(META.initialMembers && META.initialMembers.length){
250
+ for(const q of META.initialMembers){ const lo=q.toLowerCase(); ALL_MEMBERS.filter(m=>m.email.includes(lo)||m.name.toLowerCase().includes(lo)).forEach(m=>selected.add(m.email)); }
251
+ }
252
+ renderMemberMenu(); updateMemberLabel();
253
+
254
+ if(META.initialFrom||META.initialTo){ document.getElementById('from').value=META.initialFrom||''; document.getElementById('to').value=META.initialTo||''; setCustom(); }
255
+ else if(META.initialSince){ setPreset(META.initialSince); }
256
+ else { setAllTime(); }
257
+ }
258
+ init();
259
+ </script>
260
+ </body>
261
+ </html>`;
262
+ }
263
+
264
+ module.exports = { buildReport };