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.
- package/README.md +77 -0
- package/cli.mjs +208 -0
- package/index.html +243 -0
- 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
|
+
}
|