cc-gap 1.0.0

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 (4) hide show
  1. package/README.md +77 -0
  2. package/cli.mjs +208 -0
  3. package/index.html +243 -0
  4. package/package.json +26 -0
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # cc-gap
2
+
3
+ **How long does your AI rest between sessions?**
4
+
5
+ Analyzes the time gaps between consecutive Claude Code sessions to reveal your work rhythm — from instant compaction restarts to multi-day breaks.
6
+
7
+ ## Usage
8
+
9
+ ```bash
10
+ npx cc-gap
11
+ ```
12
+
13
+ Or use the browser version (no install):
14
+
15
+ → **[yurukusa.github.io/cc-gap](https://yurukusa.github.io/cc-gap/)**
16
+
17
+ Drag in your `~/.claude` folder. Everything runs locally — nothing is uploaded.
18
+
19
+ ## Sample output
20
+
21
+ ```
22
+ cc-gap — Time between your Claude Code sessions (All time)
23
+
24
+ 488 sessions · 487 gaps analyzed
25
+
26
+ < 1 min ████████████████████████░░░░ 119 ( 24%)
27
+ 1–5 min ███████████████░░░░░░░░░░░░░ 79 ( 16%)
28
+ 5–30 min ████████████████████████████ 148 ( 30%)
29
+ 30m–2h █████████░░░░░░░░░░░░░░░░░░░ 46 ( 9%)
30
+ 2–8 hr ██████████░░░░░░░░░░░░░░░░░░ 51 ( 10%)
31
+ 8–24 hr ██████░░░░░░░░░░░░░░░░░░░░░░ 31 ( 6%)
32
+ 1–2 days ██░░░░░░░░░░░░░░░░░░░░░░░░░░ 13 ( 3%)
33
+ 2–7 days ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0 ( 0%)
34
+ 7+ days ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0 ( 0%)
35
+
36
+ ───────────────────────────────────────────────────────
37
+ Work style: 🔄 Rapid Cycler
38
+ Quick breaks between sessions. Fast iteration rhythm.
39
+
40
+ Median gap: 7m
41
+ Mean gap: 2h 29m
42
+ P90 gap: 7h 34m
43
+ Longest gap: 1d 23h
44
+ < 1 min (compaction): 24.4% of gaps
45
+ ```
46
+
47
+ ## Work style classifications
48
+
49
+ | Type | Median gap | Description |
50
+ |------|-----------|-------------|
51
+ | ⚡ Always On | < 2 min | The AI barely pauses — continuous autonomous work |
52
+ | 🔄 Rapid Cycler | 2–30 min | Quick breaks, fast iteration rhythm |
53
+ | ⏸️ Steady Pauser | 30 min–4 hr | Natural pauses between focused bursts |
54
+ | 🌙 Daily Worker | 4–24 hr | Sessions in work windows with overnight breaks |
55
+ | 📅 Weekend Coder | > 24 hr | Infrequent sessions with multi-day breaks |
56
+
57
+ ## Options
58
+
59
+ ```bash
60
+ npx cc-gap # All-time analysis
61
+ npx cc-gap --days=30 # Last 30 days
62
+ npx cc-gap --json # JSON output for dashboards
63
+ ```
64
+
65
+ ## Part of cc-toolkit
66
+
67
+ cc-gap is tool #47 in [cc-toolkit](https://yurukusa.github.io/cc-toolkit/) — 49 free tools for Claude Code users.
68
+
69
+ Related tools:
70
+ - [cc-session-length](https://github.com/yurukusa/cc-session-length) — How long do sessions last?
71
+ - [cc-night-owl](https://github.com/yurukusa/cc-night-owl) — Which hours does your AI work most?
72
+ - [cc-streak](https://github.com/yurukusa/cc-streak) — Consecutive days of usage
73
+
74
+ ---
75
+
76
+ **GitHub**: [yurukusa/cc-gap](https://github.com/yurukusa/cc-gap)
77
+ **Try it**: `npx cc-gap`
package/cli.mjs ADDED
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ import { readdir, open } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
5
+
6
+ const VERSION = '1.0.0';
7
+ const MAX_CHUNK = 4096;
8
+
9
+ function parseArgs(argv) {
10
+ const args = { days: 0, json: false, utc: false };
11
+ for (const a of argv.slice(2)) {
12
+ if (a.startsWith('--days=')) args.days = parseInt(a.slice(7)) || 0;
13
+ else if (a === '--json') args.json = true;
14
+ else if (a === '--utc') args.utc = true;
15
+ else if (a === '--help' || a === '-h') {
16
+ console.log([
17
+ `cc-gap v${VERSION}`,
18
+ '',
19
+ 'Usage: cc-gap [options]',
20
+ '',
21
+ 'Options:',
22
+ ' --days=N Only analyze sessions from the last N days',
23
+ ' --json Output JSON for piping',
24
+ ' --help Show this help',
25
+ '',
26
+ 'Shows the distribution of time gaps between consecutive Claude Code sessions.',
27
+ ].join('\n'));
28
+ process.exit(0);
29
+ }
30
+ }
31
+ return args;
32
+ }
33
+
34
+ async function getFirstTimestamp(path) {
35
+ let fh;
36
+ try {
37
+ fh = await open(path, 'r');
38
+ const buf = Buffer.alloc(MAX_CHUNK);
39
+ const { bytesRead } = await fh.read(buf, 0, MAX_CHUNK, 0);
40
+ const text = buf.subarray(0, bytesRead).toString('utf8');
41
+ for (const line of text.split('\n')) {
42
+ if (!line.trim()) continue;
43
+ try {
44
+ const d = JSON.parse(line);
45
+ if (d.timestamp) return new Date(d.timestamp);
46
+ } catch {}
47
+ }
48
+ } catch {}
49
+ finally { await fh?.close(); }
50
+ return null;
51
+ }
52
+
53
+ async function collectStarts(claudeDir, cutoff) {
54
+ const starts = [];
55
+ let projectDirs;
56
+ try { projectDirs = await readdir(claudeDir); } catch { return starts; }
57
+
58
+ for (const pd of projectDirs) {
59
+ const pdPath = join(claudeDir, pd);
60
+ let files;
61
+ try { files = await readdir(pdPath); } catch { continue; }
62
+ for (const name of files) {
63
+ if (!name.endsWith('.jsonl')) continue;
64
+ const ts = await getFirstTimestamp(join(pdPath, name));
65
+ if (!ts) continue;
66
+ if (cutoff && ts < cutoff) continue;
67
+ starts.push(ts);
68
+ }
69
+ }
70
+ return starts;
71
+ }
72
+
73
+ const BUCKETS = [
74
+ { label: '< 1 min', min: 0, max: 1, cls: 'instant' },
75
+ { label: '1–5 min', min: 1, max: 5, cls: 'quick' },
76
+ { label: '5–30 min', min: 5, max: 30, cls: 'quick' },
77
+ { label: '30m–2h', min: 30, max: 120, cls: 'medium' },
78
+ { label: '2–8 hr', min: 120, max: 480, cls: 'medium' },
79
+ { label: '8–24 hr', min: 480, max: 1440, cls: 'long' },
80
+ { label: '1–2 days', min: 1440, max: 2880, cls: 'long' },
81
+ { label: '2–7 days', min: 2880, max: 10080, cls: 'long' },
82
+ { label: '7+ days', min: 10080, max: Infinity, cls: 'long' },
83
+ ];
84
+
85
+ function fmtGap(min) {
86
+ if (min < 1) return `${Math.round(min * 60)}s`;
87
+ if (min < 60) return `${Math.round(min)}m`;
88
+ const h = Math.floor(min / 60);
89
+ const m = Math.round(min % 60);
90
+ if (h < 24) return m ? `${h}h ${m}m` : `${h}h`;
91
+ const d = Math.floor(h / 24);
92
+ const rh = h % 24;
93
+ return rh ? `${d}d ${rh}h` : `${d}d`;
94
+ }
95
+
96
+ function median(sorted) {
97
+ if (!sorted.length) return 0;
98
+ const m = Math.floor(sorted.length / 2);
99
+ return sorted.length % 2 ? sorted[m] : (sorted[m - 1] + sorted[m]) / 2;
100
+ }
101
+
102
+ function mean(arr) {
103
+ return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
104
+ }
105
+
106
+ function classify(medianMin, pctInstant) {
107
+ if (pctInstant >= 0.5 || medianMin < 2)
108
+ return { label: '⚡ Always On', desc: 'Your AI barely pauses. Instant restarts, continuous work.' };
109
+ if (medianMin < 30)
110
+ return { label: '🔄 Rapid Cycler', desc: 'Quick breaks between sessions. Fast iteration rhythm.' };
111
+ if (medianMin < 240)
112
+ return { label: '⏸️ Steady Pauser', desc: 'Natural pauses between focused bursts.' };
113
+ if (medianMin < 1440)
114
+ return { label: '🌙 Daily Worker', desc: 'Sessions cluster in work windows with overnight breaks.' };
115
+ return { label: '📅 Weekend Coder', desc: 'Infrequent sessions with multi-day breaks between.' };
116
+ }
117
+
118
+ async function main() {
119
+ const args = parseArgs(process.argv);
120
+ const claudeDir = join(homedir(), '.claude', 'projects');
121
+ const cutoff = args.days ? new Date(Date.now() - args.days * 86400000) : null;
122
+
123
+ const starts = await collectStarts(claudeDir, cutoff);
124
+ if (starts.length < 2) {
125
+ console.log('Not enough sessions to compute gaps.');
126
+ process.exit(0);
127
+ }
128
+
129
+ starts.sort((a, b) => a - b);
130
+
131
+ const gapsMin = [];
132
+ for (let i = 1; i < starts.length; i++) {
133
+ const g = (starts[i] - starts[i - 1]) / 60000;
134
+ if (g >= 0 && g < 365 * 24 * 60) gapsMin.push(g);
135
+ }
136
+
137
+ const sorted = [...gapsMin].sort((a, b) => a - b);
138
+ const total = sorted.length;
139
+ const med = median(sorted);
140
+ const avg = mean(sorted);
141
+ const maxGap = sorted[total - 1];
142
+ const pctInstant = sorted.filter(g => g < 1).length / total;
143
+ const p90 = sorted[Math.floor(total * 0.9)];
144
+ const cls = classify(med, pctInstant);
145
+
146
+ const bucketCounts = BUCKETS.map(b => ({
147
+ ...b,
148
+ count: sorted.filter(g => g >= b.min && g < b.max).length,
149
+ }));
150
+
151
+ if (args.json) {
152
+ console.log(JSON.stringify({
153
+ sessions: starts.length,
154
+ gaps: total,
155
+ median: med,
156
+ mean: avg,
157
+ max: maxGap,
158
+ p90,
159
+ pctInstant,
160
+ classification: cls.label,
161
+ buckets: bucketCounts.map(b => ({ label: b.label, count: b.count, pct: b.count / total * 100 })),
162
+ }, null, 2));
163
+ return;
164
+ }
165
+
166
+ const scope = args.days ? `Last ${args.days} days` : 'All time';
167
+
168
+ const C = {
169
+ reset: '\x1b[0m', bold: '\x1b[1m',
170
+ purple: '\x1b[35m', cyan: '\x1b[36m',
171
+ yellow: '\x1b[33m', orange: '\x1b[38;5;214m',
172
+ muted: '\x1b[90m', green: '\x1b[32m',
173
+ };
174
+
175
+ const maxCount = Math.max(...bucketCounts.map(b => b.count), 1);
176
+
177
+ console.log(`\n${C.cyan}${C.bold}cc-gap${C.reset} — Time between your Claude Code sessions (${scope})\n`);
178
+ console.log(`${C.muted}${starts.length} sessions · ${total} gaps analyzed${C.reset}\n`);
179
+
180
+ for (const b of bucketCounts) {
181
+ const pct = (b.count / total * 100).toFixed(0);
182
+ const barWidth = Math.round(b.count / maxCount * 28);
183
+ const color = b.cls === 'instant' ? C.purple
184
+ : b.cls === 'quick' ? C.cyan
185
+ : b.cls === 'medium' ? C.yellow
186
+ : C.orange;
187
+ const filled = '█'.repeat(barWidth);
188
+ const empty = '░'.repeat(28 - barWidth);
189
+ console.log(
190
+ ` ${C.muted}${b.label.padEnd(10)}${C.reset} ` +
191
+ `${color}${filled}${C.muted}${empty}${C.reset} ` +
192
+ `${C.muted}${String(b.count).padStart(4)} (${String(pct).padStart(3)}%)${C.reset}`
193
+ );
194
+ }
195
+
196
+ console.log(`\n${'─'.repeat(55)}`);
197
+ console.log(` ${C.cyan}Work style:${C.reset} ${C.bold}${cls.label}${C.reset}`);
198
+ console.log(` ${C.muted}${cls.desc}${C.reset}`);
199
+
200
+ console.log(`\n ${C.muted}Median gap:${C.reset} ${C.bold}${fmtGap(med)}${C.reset}`);
201
+ console.log(` ${C.muted}Mean gap:${C.reset} ${C.bold}${fmtGap(avg)}${C.reset}`);
202
+ console.log(` ${C.muted}P90 gap:${C.reset} ${C.bold}${fmtGap(p90)}${C.reset}`);
203
+ console.log(` ${C.muted}Longest gap:${C.reset} ${C.orange}${C.bold}${fmtGap(maxGap)}${C.reset}`);
204
+ console.log(` ${C.muted}< 1 min (compaction):${C.reset} ${(pctInstant * 100).toFixed(1)}% of gaps`);
205
+ console.log();
206
+ }
207
+
208
+ main().catch(e => { console.error(e.message); process.exit(1); });
package/index.html ADDED
@@ -0,0 +1,243 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>cc-gap — Time between your Claude Code sessions</title>
7
+ <meta name="description" content="How long does your AI rest between sessions? Drop your ~/.claude folder to see the gap distribution and discover your work rhythm.">
8
+ <meta property="og:title" content="cc-gap — Session gap analyzer for Claude Code">
9
+ <meta property="og:description" content="From 11-second compaction restarts to 2-day breaks — see how long Claude Code rests between sessions.">
10
+ <meta property="og:url" content="https://yurukusa.github.io/cc-gap/">
11
+ <meta name="twitter:card" content="summary">
12
+ <meta name="twitter:site" content="@yurukusa_dev">
13
+ <style>
14
+ * { margin: 0; padding: 0; box-sizing: border-box; }
15
+ :root {
16
+ --bg: #0d1117; --surface: #161b22; --border: #30363d;
17
+ --text: #e6edf3; --muted: #8b949e;
18
+ --purple: #bc8cff; --cyan: #58d8f0; --yellow: #f7c948; --orange: #ffa657;
19
+ --blue: #58a6ff; --green: #56d364;
20
+ }
21
+ body {
22
+ background: var(--bg); color: var(--text);
23
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Consolas, monospace;
24
+ min-height: 100vh; display: flex; flex-direction: column;
25
+ align-items: center; padding: 2rem 1rem;
26
+ }
27
+ .header { text-align: center; margin-bottom: 2rem; }
28
+ .header h1 { font-size: 1.8rem; font-weight: 700; color: var(--cyan); margin-bottom: 0.5rem; }
29
+ .header p { color: var(--muted); font-size: 0.9rem; }
30
+ .drop-zone {
31
+ width: 100%; max-width: 600px; border: 2px dashed var(--border);
32
+ border-radius: 12px; padding: 3rem 2rem; text-align: center;
33
+ cursor: pointer; transition: border-color 0.2s, background 0.2s;
34
+ margin-bottom: 2rem; background: var(--surface);
35
+ }
36
+ .drop-zone:hover, .drop-zone.drag-over { border-color: var(--cyan); background: #0f1f2a; }
37
+ .drop-zone h2 { font-size: 1.1rem; margin-bottom: 0.75rem; }
38
+ .drop-zone p { color: var(--muted); font-size: 0.85rem; line-height: 1.5; }
39
+ .drop-zone .icon { font-size: 2.5rem; margin-bottom: 1rem; }
40
+ .drop-zone input[type="file"] { display: none; }
41
+ #results { width: 100%; max-width: 700px; display: none; }
42
+ .meta { font-size: 0.8rem; color: var(--muted); margin-bottom: 1.5rem; text-align: center; }
43
+ .card {
44
+ background: var(--surface); border: 1px solid var(--border);
45
+ border-radius: 8px; padding: 1.25rem; margin-bottom: 1.5rem;
46
+ }
47
+ .card-title { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 1rem; }
48
+ .row {
49
+ display: flex; align-items: center; gap: 0.5rem;
50
+ margin-bottom: 0.3rem; font-size: 0.78rem;
51
+ }
52
+ .row-label { color: var(--muted); min-width: 5.5rem; font-family: Consolas, monospace; }
53
+ .bar-wrap { flex: 1; height: 14px; background: #21262d; border-radius: 3px; overflow: hidden; }
54
+ .bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s ease; }
55
+ .bar-fill.instant { background: var(--purple); }
56
+ .bar-fill.quick { background: var(--cyan); }
57
+ .bar-fill.medium { background: var(--yellow); }
58
+ .bar-fill.long { background: var(--orange); }
59
+ .row-count { color: var(--muted); min-width: 5rem; text-align: right; font-family: Consolas, monospace; font-size: 0.75rem; }
60
+ .score-card { text-align: center; }
61
+ .cls-label { font-size: 1.5rem; font-weight: 700; color: var(--cyan); margin-bottom: 0.5rem; }
62
+ .cls-desc { font-size: 0.85rem; color: var(--muted); margin-bottom: 1rem; }
63
+ .stats-grid {
64
+ display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; margin-top: 1rem;
65
+ }
66
+ @media (min-width: 480px) { .stats-grid { grid-template-columns: repeat(4, 1fr); } }
67
+ .stat { text-align: center; }
68
+ .stat .num { font-size: 1.2rem; font-weight: 700; display: block; }
69
+ .stat .lbl { font-size: 0.7rem; color: var(--muted); }
70
+ .footer { margin-top: 2rem; text-align: center; font-size: 0.8rem; color: var(--muted); }
71
+ .footer a { color: var(--blue); text-decoration: none; margin: 0 0.5rem; }
72
+ .btn {
73
+ background: var(--cyan); color: #0d1117; border: none;
74
+ border-radius: 6px; padding: 0.6rem 1.5rem; font-size: 0.9rem;
75
+ font-weight: 600; cursor: pointer; margin-top: 1rem; font-family: inherit;
76
+ }
77
+ .btn:hover { opacity: 0.85; }
78
+ </style>
79
+ </head>
80
+ <body>
81
+ <div class="header">
82
+ <h1>cc-gap</h1>
83
+ <p>Time between your Claude Code sessions</p>
84
+ </div>
85
+ <div class="drop-zone" id="dropZone" onclick="document.getElementById('fi').click()">
86
+ <div class="icon">⏳</div>
87
+ <h2>Drop your .claude folder here</h2>
88
+ <p>Or click to select it.<br>Your data stays local — nothing is uploaded.</p>
89
+ <input type="file" id="fi" webkitdirectory multiple>
90
+ <button class="btn" onclick="event.stopPropagation(); document.getElementById('fi').click()">
91
+ Select ~/.claude folder
92
+ </button>
93
+ </div>
94
+ <div id="results">
95
+ <div class="meta" id="metaLine"></div>
96
+ <div class="card score-card" id="scoreCard"></div>
97
+ <div class="card">
98
+ <div class="card-title">Gap distribution (time between sessions)</div>
99
+ <div id="chart"></div>
100
+ </div>
101
+ <div class="footer">
102
+ <a href="https://github.com/yurukusa/cc-gap" target="_blank">GitHub</a> ·
103
+ <a href="https://yurukusa.github.io/cc-toolkit/" target="_blank">cc-toolkit (106 free tools)</a> ·
104
+ <span>Also: <code>npx cc-gap</code></span>
105
+ </div>
106
+ </div>
107
+ <div class="footer" id="footerMain">
108
+ <a href="https://github.com/yurukusa/cc-gap" target="_blank">GitHub</a> ·
109
+ <a href="https://yurukusa.github.io/cc-toolkit/" target="_blank">cc-toolkit</a> ·
110
+ <span>Part of 106 free tools for Claude Code</span>
111
+ </div>
112
+ <script>
113
+ const dropZone = document.getElementById('dropZone');
114
+ const fi = document.getElementById('fi');
115
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
116
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
117
+ dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); processFiles(Array.from(e.dataTransfer.files)); });
118
+ fi.addEventListener('change', e => processFiles(Array.from(e.target.files)));
119
+
120
+ const BUCKETS = [
121
+ { label: '< 1 min', min: 0, max: 1, cls: 'instant' },
122
+ { label: '1–5 min', min: 1, max: 5, cls: 'quick' },
123
+ { label: '5–30 min', min: 5, max: 30, cls: 'quick' },
124
+ { label: '30m–2h', min: 30, max: 120, cls: 'medium' },
125
+ { label: '2–8 hr', min: 120, max: 480, cls: 'medium' },
126
+ { label: '8–24 hr', min: 480, max: 1440, cls: 'long' },
127
+ { label: '1–2 days', min: 1440, max: 2880, cls: 'long' },
128
+ { label: '2–7 days', min: 2880, max: 10080, cls: 'long' },
129
+ { label: '7+ days', min: 10080,max: Infinity, cls: 'long' },
130
+ ];
131
+
132
+ function fmtGap(min) {
133
+ if (min < 1) return `${Math.round(min * 60)}s`;
134
+ if (min < 60) return `${Math.round(min)}m`;
135
+ const h = Math.floor(min / 60);
136
+ const m = Math.round(min % 60);
137
+ if (h < 24) return m ? `${h}h ${m}m` : `${h}h`;
138
+ const d = Math.floor(h / 24);
139
+ const rh = h % 24;
140
+ return rh ? `${d}d ${rh}h` : `${d}d`;
141
+ }
142
+
143
+ function median(arr) {
144
+ if (!arr.length) return 0;
145
+ const s = [...arr].sort((a, b) => a - b);
146
+ const m = Math.floor(s.length / 2);
147
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
148
+ }
149
+
150
+ function classify(med, pctInstant) {
151
+ if (pctInstant >= 0.5 || med < 2)
152
+ return { label: '⚡ Always On', desc: 'Your AI barely pauses. Instant restarts, continuous work.', color: 'var(--purple)' };
153
+ if (med < 30)
154
+ return { label: '🔄 Rapid Cycler', desc: 'Quick breaks between sessions. Fast iteration rhythm.', color: 'var(--cyan)' };
155
+ if (med < 240)
156
+ return { label: '⏸️ Steady Pauser', desc: 'Natural pauses between focused bursts.', color: 'var(--yellow)' };
157
+ if (med < 1440)
158
+ return { label: '🌙 Daily Worker', desc: 'Sessions cluster in work windows with overnight breaks.', color: 'var(--orange)' };
159
+ return { label: '📅 Weekend Coder', desc: 'Infrequent sessions with multi-day breaks between.', color: 'var(--orange)' };
160
+ }
161
+
162
+ async function processFiles(files) {
163
+ const sessionFiles = files.filter(f => {
164
+ const p = f.webkitRelativePath;
165
+ return p.endsWith('.jsonl') &&
166
+ p.includes('projects') &&
167
+ !p.includes('/subagents/');
168
+ });
169
+ if (!sessionFiles.length) {
170
+ alert('No session files found.\nSelect your .claude folder (not a subfolder).');
171
+ return;
172
+ }
173
+
174
+ const starts = [];
175
+ for (const f of sessionFiles) {
176
+ try {
177
+ const text = await f.slice(0, 4096).text();
178
+ for (const line of text.split('\n')) {
179
+ if (!line.trim()) continue;
180
+ try {
181
+ const d = JSON.parse(line);
182
+ if (d.timestamp) { starts.push(new Date(d.timestamp)); break; }
183
+ } catch {}
184
+ }
185
+ } catch {}
186
+ }
187
+
188
+ if (starts.length < 2) {
189
+ alert('Not enough sessions to compute gaps.');
190
+ return;
191
+ }
192
+
193
+ starts.sort((a, b) => a - b);
194
+ const gapsMin = [];
195
+ for (let i = 1; i < starts.length; i++) {
196
+ const g = (starts[i] - starts[i - 1]) / 60000;
197
+ if (g >= 0 && g < 365 * 24 * 60) gapsMin.push(g);
198
+ }
199
+
200
+ const sorted = [...gapsMin].sort((a, b) => a - b);
201
+ const total = sorted.length;
202
+ const med = median(sorted);
203
+ const avg = gapsMin.reduce((s, v) => s + v, 0) / total;
204
+ const maxVal = sorted[total - 1];
205
+ const p90 = sorted[Math.floor(total * 0.9)];
206
+ const pctInstant = sorted.filter(g => g < 1).length / total;
207
+
208
+ document.getElementById('metaLine').textContent = `${starts.length} sessions · ${total} gaps analyzed`;
209
+ dropZone.style.display = 'none';
210
+ document.getElementById('footerMain').style.display = 'none';
211
+ document.getElementById('results').style.display = 'block';
212
+
213
+ const bucketCounts = BUCKETS.map(b => ({
214
+ ...b,
215
+ count: sorted.filter(g => g >= b.min && g < b.max).length,
216
+ }));
217
+ const maxCount = Math.max(...bucketCounts.map(b => b.count), 1);
218
+
219
+ document.getElementById('chart').innerHTML = bucketCounts.map(b => {
220
+ const pct = (b.count / total * 100).toFixed(1);
221
+ const barPct = (b.count / maxCount * 100).toFixed(0);
222
+ return `<div class="row">
223
+ <span class="row-label">${b.label}</span>
224
+ <div class="bar-wrap"><div class="bar-fill ${b.cls}" style="width:${barPct}%"></div></div>
225
+ <span class="row-count">${b.count} (${pct}%)</span>
226
+ </div>`;
227
+ }).join('');
228
+
229
+ const cls = classify(med, pctInstant);
230
+ document.getElementById('scoreCard').innerHTML = `
231
+ <div class="card-title">Work rhythm</div>
232
+ <div class="cls-label" style="color:${cls.color}">${cls.label}</div>
233
+ <div class="cls-desc">${cls.desc}</div>
234
+ <div class="stats-grid">
235
+ <div class="stat"><span class="num" style="color:${cls.color}">${fmtGap(med)}</span><span class="lbl">median gap</span></div>
236
+ <div class="stat"><span class="num">${fmtGap(avg)}</span><span class="lbl">mean gap</span></div>
237
+ <div class="stat"><span class="num" style="color:var(--orange)">${fmtGap(maxVal)}</span><span class="lbl">longest gap</span></div>
238
+ <div class="stat"><span class="num">${(pctInstant * 100).toFixed(1)}%</span><span class="lbl">< 1 min (compact)</span></div>
239
+ </div>`;
240
+ }
241
+ </script>
242
+ </body>
243
+ </html>
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "cc-gap",
3
+ "version": "1.0.0",
4
+ "description": "How much time passes between your Claude Code sessions? Gap distribution and work rhythm analysis.",
5
+ "type": "module",
6
+ "bin": {
7
+ "cc-gap": "./cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "start": "node cli.mjs"
11
+ },
12
+ "keywords": [
13
+ "claude-code",
14
+ "claude",
15
+ "ai",
16
+ "session",
17
+ "productivity",
18
+ "analytics",
19
+ "cli"
20
+ ],
21
+ "author": "yurukusa",
22
+ "license": "MIT",
23
+ "engines": {
24
+ "node": ">=18"
25
+ }
26
+ }