vibestreak 0.1.1 → 0.2.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.
@@ -2,17 +2,25 @@
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { parseArgs } from 'node:util'
4
4
 
5
- import { autosyncStatus, clock, installAutosync, NUDGE_TIME, nextRun, removeAutosync, SYNC_TIMES } from '../src/autosync.mjs'
6
- import { notify, nudgeAction, nudgeText, shouldNudge } from '../src/nudge.mjs'
7
- import { collect, sinceDate, summarize } from '../src/collect.mjs'
5
+ import { autosyncStatus, clock, installAutosync, nextRun, removeAutosync, SYNC_TIMES } from '../src/autosync.mjs'
6
+ import { NUDGE_DEFAULT, notify, nudgeAction, nudgeText, nudgeTimeOf, parseNudgeTime, shouldNudge } from '../src/nudge.mjs'
7
+ import { collect, sinceDate, summarize, syncSince } from '../src/collect.mjs'
8
8
  import { CONFIG_PATH, loadConfig, saveConfig } from '../src/config.mjs'
9
9
  import { DEFAULT_ROOTS, globalEmail, scanEmails } from '../src/git.mjs'
10
+ import { boardView, dashboardView, profileView, todayView } from '../src/views.mjs'
10
11
 
11
12
  const BRAND = process.env.VIBESTREAK_BRAND ?? 'vibestreak'
12
13
  const DEFAULT_SERVER = process.env.VIBESTREAK_SERVER ?? 'https://vibestreak.kingsbridge-consultancy.com'
13
14
 
14
15
  const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and how steadily.
15
16
 
17
+ ${BRAND} your streak, heatmap and rank at a glance
18
+ ${BRAND} me your profile: streak, heatmap, totals
19
+ ${BRAND} profile <handle> someone else's public profile
20
+ ${BRAND} board [today|7d|30d|90d|all] [consistency|shipped|streak|tokens] [tool]
21
+ the leaderboard (--from/--to for your own dates)
22
+ ${BRAND} today who is coding with AI today; streaks at risk
23
+
16
24
  ${BRAND} collect [--json] preview what would be sent (nothing leaves the machine)
17
25
  ${BRAND} login --token <t> [--server <url>]
18
26
  link this machine (token from your settings page)
@@ -25,6 +33,7 @@ const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and h
25
33
  ${BRAND} autosync [on|off] sync in the background through the day
26
34
  ${BRAND} nudge [on|off|test] an evening notification when your streak is
27
35
  about to end (on with autosync); alone, its status
36
+ ${BRAND} nudge at 21:45 when it checks (17:00 to 23:30; default 20:30)
28
37
  ${BRAND} whoami the profile this machine syncs to
29
38
 
30
39
  Reads: Claude Code, Codex, Cursor, Copilot (CLI and VS Code), Gemini CLI,
@@ -57,14 +66,40 @@ async function main() {
57
66
  token: { type: 'string' },
58
67
  server: { type: 'string' },
59
68
  since: { type: 'string' },
69
+ full: { type: 'boolean' },
60
70
  help: { type: 'boolean', short: 'h' },
71
+ from: { type: 'string' },
72
+ to: { type: 'string' },
73
+ limit: { type: 'string' },
61
74
  },
62
75
  })
63
- const [command = 'help', ...rest] = positionals
76
+ const [command = 'dashboard', ...rest] = positionals
64
77
  const config = await loadConfig()
65
78
  const log = (...args) => values.quiet || console.log(...args)
66
79
 
67
80
  if (command === 'help' || values.help) return console.log(HELP)
81
+ const columns = process.stdout.columns || 100
82
+ const server = config.server ?? DEFAULT_SERVER
83
+
84
+ if (command === 'dashboard') {
85
+ // Plain `vibestreak`: the dashboard once linked; before that, how to start.
86
+ if (!config.token) return console.log(`${HELP}\n\nGet started: ${server.replace(/\/$/, '')}/cli`)
87
+ const me = await api(config, '/api/v1/me')
88
+ return dashboardView({ server, token: config.token, handle: me.handle, columns })
89
+ }
90
+ if (command === 'me') {
91
+ const me = await api(config, '/api/v1/me')
92
+ return profileView({ server, token: config.token, handle: me.handle, columns })
93
+ }
94
+ if (command === 'profile') {
95
+ if (!rest[0]) throw new Error(`Whose? \`${BRAND} profile <handle>\` (yours: \`${BRAND} me\`).`)
96
+ return profileView({ server, token: config.token, handle: rest[0].replace(/^@/, ''), columns })
97
+ }
98
+ if (command === 'board') {
99
+ const you = config.token ? (await api(config, '/api/v1/me').catch(() => null))?.handle : undefined
100
+ return boardView({ server, words: rest, values, you, columns })
101
+ }
102
+ if (command === 'today') return todayView({ server })
68
103
 
69
104
  if (command === 'emails') {
70
105
  const [action, email] = rest
@@ -123,11 +158,21 @@ async function main() {
123
158
  const bin = fileURLToPath(import.meta.url)
124
159
  const action = nudgeAction(rest, values)
125
160
  if (action === 'status') return nudgeStatus(config)
161
+ if (action === 'at') {
162
+ const time = parseNudgeTime(rest[1])
163
+ config.nudgeTime = clock(time)
164
+ if (rest[1].trim().toLowerCase() === 'default') delete config.nudgeTime
165
+ await saveConfig(config)
166
+ const scheduled = await autosyncStatus()
167
+ if (scheduled.on && config.nudge !== false) await installAutosync(bin, { nudge: true, nudgeTime: time })
168
+ const note = config.nudge === false ? ` It's off: \`${BRAND} nudge on\` turns it on.` : !scheduled.on ? ` It runs once autosync is on: \`${BRAND} autosync on\`.` : ''
169
+ return console.log(`Streak check at ${clock(time)}${clock(time) === clock(NUDGE_DEFAULT) ? ' (the default)' : ''}.${note}`)
170
+ }
126
171
  if (action === 'on' || action === 'off') {
127
172
  config.nudge = action === 'on'
128
173
  await saveConfig(config)
129
- if (config.autosync) await installAutosync(bin, { nudge: config.nudge })
130
- return console.log(`Streak nudge ${config.nudge ? 'on: 20:30 each evening when your streak is at risk' : 'off'}.`)
174
+ if (config.autosync) await installAutosync(bin, { nudge: config.nudge, nudgeTime: nudgeTimeOf(config) })
175
+ return console.log(`Streak nudge ${config.nudge ? `on: ${clock(nudgeTimeOf(config))} each evening when your streak is at risk` : 'off'}.`)
131
176
  }
132
177
  if (action === 'test') {
133
178
  const { streak } = await api(config, '/api/v1/me')
@@ -151,7 +196,7 @@ async function main() {
151
196
  await saveConfig(config)
152
197
  return console.log(await removeAutosync())
153
198
  }
154
- const result = await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false })
199
+ const result = await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false, nudgeTime: nudgeTimeOf(config) })
155
200
  config.autosync = result.installed
156
201
  await saveConfig(config)
157
202
  return console.log(
@@ -175,7 +220,7 @@ async function sync(config, values, log, isSync) {
175
220
  if (first) emails.push(first)
176
221
  }
177
222
  const started = Date.now()
178
- const payload = await collect({ config: { ...config, emails }, salt: me?.salt ?? config.localSalt, since: values.since })
223
+ const payload = await collect({ config: { ...config, emails }, salt: me?.salt ?? config.localSalt, since: isSync ? syncSince(config, values) : values.since })
179
224
  if (values.json) return console.log(JSON.stringify(payload, null, 2))
180
225
  const s = summarize(payload)
181
226
  log(`${s.activeDays} active days since ${payload.since} (${((Date.now() - started) / 1000).toFixed(1)}s)`)
@@ -186,7 +231,9 @@ async function sync(config, values, log, isSync) {
186
231
  if (!isSync) return log(`\nNothing was sent. \`${BRAND} sync\` uploads these totals.`)
187
232
  const result = await api(config, '/api/v1/upload', { method: 'POST', body: JSON.stringify(payload) })
188
233
  config.lastSync = new Date().toISOString()
234
+ config.lastSyncServer = config.server
189
235
  config.lastSyncSummary = { days: result.days, commits: result.commits, repos: s.repos }
236
+ if (result.underReview) console.log(`Your profile is under review and off the boards for now: see ${new URL('/settings#trust', config.server)}`)
190
237
  await saveConfig(config)
191
238
  // Always one line, quiet or not: the background sync's log is how a person sees it ran.
192
239
  const streak = result.streak ? ` · streak ${result.streak.current}d${result.streak.activeToday ? '' : ' (not yet today)'}` : ''
@@ -222,11 +269,12 @@ async function nudgeStatus(config) {
222
269
  const [scheduled, me] = await Promise.all([autosyncStatus(), serverStatus(config)])
223
270
  const on = config.nudge !== false && scheduled.nudge
224
271
  const why = config.nudge === false ? ` (\`${BRAND} nudge on\` turns it on)` : !scheduled.on ? ` (needs autosync: \`${BRAND} autosync on\`)` : ''
225
- console.log(`Nudge ${on ? `on · checks at ${clock(NUDGE_TIME)}` : `off${why}`}`)
272
+ const time = clock(nudgeTimeOf(config))
273
+ console.log(`Nudge ${on ? `on · checks at ${time}${config.nudgeTime ? '' : ' (default; `nudge at 21:45` changes it)'}` : `off${why}`}`)
226
274
  console.log(`Last nudge ${config.lastNudge ?? 'never'}`)
227
275
  if (me.error) return console.log(`Tonight unknown (${me.error})`)
228
276
  const s = me.streak
229
- const tonight = !on ? 'no check' : s.activeToday ? 'no nudge: you were active today' : s.current > 0 ? (config.lastNudge === s.today ? 'already nudged today' : `will nudge at ${clock(NUDGE_TIME)} unless you code with AI first`) : 'no nudge: no live streak'
277
+ const tonight = !on ? 'no check' : s.activeToday ? 'no nudge: you were active today' : s.current > 0 ? (config.lastNudge === s.today ? 'already nudged today' : `will nudge at ${time} unless you code with AI first`) : 'no nudge: no live streak'
230
278
  console.log(`Streak ${streakLine(s)}`)
231
279
  console.log(`Tonight ${tonight}`)
232
280
  }
@@ -242,7 +290,7 @@ async function status(config) {
242
290
  if (!me.error) row('Streak', streakLine(me.streak))
243
291
  row('Autosync', scheduled.on ? `on (${scheduled.using}) · next sync ${at(nextRun(SYNC_TIMES))}` : `off: \`${BRAND} autosync on\` syncs at ${SYNC_TIMES.map(clock).join(', ')}`)
244
292
  const nudgeOn = config.nudge !== false && scheduled.nudge
245
- row('Nudge', nudgeOn ? `on · checks at ${clock(NUDGE_TIME)} · last nudged ${config.lastNudge ?? 'never'}` : config.nudge === false ? 'off' : 'off (needs autosync)')
293
+ row('Nudge', nudgeOn ? `on · checks at ${clock(nudgeTimeOf(config))} · last nudged ${config.lastNudge ?? 'never'}` : config.nudge === false ? 'off' : 'off (needs autosync)')
246
294
  const github = me.error ? 0 : (me.emails?.length ?? 0)
247
295
  row('Addresses', `${config.emails.join(', ') || 'none'}${github ? ` · +${github} from GitHub` : ''}`)
248
296
  row('Config', CONFIG_PATH)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibestreak",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Your AI coding streak: a heatmap of the days you coded with Claude Code, Codex, Cursor, Copilot, Gemini and OpenCode, and the commits you shipped with them. Sends daily totals only.",
5
5
  "keywords": ["claude-code", "codex", "cursor", "copilot", "gemini", "opencode", "ai-coding", "heatmap", "streak", "leaderboard", "cli"],
6
6
  "homepage": "https://vibestreak.kingsbridge-consultancy.com/cli",
package/src/autosync.mjs CHANGED
@@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'
6
6
  import { promisify } from 'node:util'
7
7
 
8
8
  import { CONFIG_PATH } from './config.mjs'
9
+ import { NUDGE_DEFAULT } from './nudge.mjs'
9
10
 
10
11
  const run = promisify(execFile)
11
12
 
@@ -16,16 +17,17 @@ export const SYNC_TIMES = [
16
17
  [13, 17],
17
18
  [17, 17],
18
19
  ]
19
- export const NUDGE_TIME = [20, 30]
20
+ export { NUDGE_DEFAULT as NUDGE_TIME }
20
21
 
21
22
  export const clock = ([h, m]) => `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
22
23
  const configDir = () => dirname(CONFIG_PATH)
23
24
  const logPath = () => join(configDir(), 'sync.log')
24
25
 
25
- // The jobs to schedule: a sync at each sync time, and the evening nudge.
26
- export const jobs = nudge => [
26
+ // The jobs to schedule: a sync at each sync time, and the evening nudge at
27
+ // the person's chosen time.
28
+ export const jobs = (nudge, nudgeTime = NUDGE_DEFAULT) => [
27
29
  { name: 'sync', args: ['sync', '--quiet'], times: SYNC_TIMES },
28
- ...(nudge ? [{ name: 'nudge', args: ['nudge', 'run', '--quiet'], times: [NUDGE_TIME] }] : []),
30
+ ...(nudge ? [{ name: 'nudge', args: ['nudge', 'run', '--quiet'], times: [nudgeTime] }] : []),
29
31
  ]
30
32
 
31
33
  // A CLI run through npx lives in npm's cache, which can be cleared; a
@@ -65,9 +67,9 @@ export function launchdPlist(label, node, bin, args, times, log) {
65
67
  }
66
68
 
67
69
  const launchd = {
68
- async install(node, bin, nudge) {
70
+ async install(node, bin, nudge, nudgeTime) {
69
71
  await mkdir(agents(), { recursive: true })
70
- for (const job of jobs(true)) {
72
+ for (const job of jobs(true, nudgeTime)) {
71
73
  const plist = join(agents(), `${LAUNCHD[job.name]}.plist`)
72
74
  await run('launchctl', ['unload', plist]).catch(() => {})
73
75
  if (job.name === 'nudge' && !nudge) {
@@ -129,8 +131,8 @@ const CRON_BEGIN = '# vibestreak:begin'
129
131
  const CRON_END = '# vibestreak:end'
130
132
  const shellQuote = a => `'${a.replace(/'/g, `'\\''`)}'`
131
133
 
132
- export function cronBlock(node, bin, nudge, log) {
133
- const lines = jobs(nudge).flatMap(job =>
134
+ export function cronBlock(node, bin, nudge, log, nudgeTime) {
135
+ const lines = jobs(nudge, nudgeTime).flatMap(job =>
134
136
  job.times.map(([h, m]) => `${m} ${h} * * * ${[node, bin, ...job.args].map(shellQuote).join(' ')} >> ${shellQuote(log)} 2>&1`),
135
137
  )
136
138
  return [CRON_BEGIN, ...lines, CRON_END].join('\n')
@@ -153,10 +155,10 @@ async function writeCrontab(block) {
153
155
  const hasUserSystemd = () => run('systemctl', ['--user', 'show-environment']).then(() => true, () => false)
154
156
 
155
157
  const linux = {
156
- async install(node, bin, nudge) {
158
+ async install(node, bin, nudge, nudgeTime) {
157
159
  if (await hasUserSystemd()) {
158
160
  await mkdir(units(), { recursive: true })
159
- for (const job of jobs(true)) {
161
+ for (const job of jobs(true, nudgeTime)) {
160
162
  const name = `vibestreak-${job.name}`
161
163
  if (job.name === 'nudge' && !nudge) {
162
164
  await run('systemctl', ['--user', 'disable', '--now', `${name}.timer`]).catch(() => {})
@@ -172,7 +174,7 @@ const linux = {
172
174
  for (const job of jobs(nudge)) await run('systemctl', ['--user', 'enable', '--now', `vibestreak-${job.name}.timer`])
173
175
  return 'systemd'
174
176
  }
175
- await writeCrontab(cronBlock(node, bin, nudge, logPath()))
177
+ await writeCrontab(cronBlock(node, bin, nudge, logPath(), nudgeTime))
176
178
  return 'crontab'
177
179
  },
178
180
  async status() {
@@ -202,13 +204,18 @@ export const windowsTaskName = (job, [h, m]) => `vibestreak\\${job.name}-${Strin
202
204
  const cmdPath = p => p.replace(/%/g, '%%')
203
205
  export const windowsScript = (node, bin, job, log) => `@echo off\r\n"${cmdPath(node)}" "${cmdPath(bin)}" ${job.args.join(' ')} >> "${cmdPath(log)}" 2>&1\r\n`
204
206
 
205
- const ALL_WINDOWS_TASKS = jobs(true).flatMap(job => job.times.map(t => windowsTaskName(job, t)))
207
+ // Our tasks as Task Scheduler holds them, whatever times they were made for.
208
+ async function windowsTasks() {
209
+ const { stdout } = await run('schtasks', ['/Query', '/FO', 'CSV', '/NH']).catch(() => ({ stdout: '' }))
210
+ const names = stdout.split(/\r?\n/).map(line => /^"([^"]+)"/.exec(line)?.[1]).filter(Boolean)
211
+ return [...new Set(names.filter(n => /^\\vibestreak\\/i.test(n)).map(n => n.slice(1)))]
212
+ }
206
213
 
207
214
  const windows = {
208
- async install(node, bin, nudge) {
215
+ async install(node, bin, nudge, nudgeTime) {
209
216
  await windows.remove()
210
217
  await mkdir(configDir(), { recursive: true })
211
- for (const job of jobs(nudge)) {
218
+ for (const job of jobs(nudge, nudgeTime)) {
212
219
  const script = join(configDir(), `${job.name}.cmd`)
213
220
  await writeFile(script, windowsScript(node, bin, job, logPath()))
214
221
  for (const time of job.times) {
@@ -218,12 +225,11 @@ const windows = {
218
225
  return 'Task Scheduler'
219
226
  },
220
227
  async status() {
221
- const has = name => run('schtasks', ['/Query', '/TN', name]).then(() => true, () => false)
222
- const [syncJob, nudgeJob] = jobs(true)
223
- return { on: await has(windowsTaskName(syncJob, syncJob.times[0])), using: 'Task Scheduler', nudge: await has(windowsTaskName(nudgeJob, NUDGE_TIME)) }
228
+ const names = await windowsTasks()
229
+ return { on: names.some(n => /\\sync-/i.test(n)), using: 'Task Scheduler', nudge: names.some(n => /\\nudge-/i.test(n)) }
224
230
  },
225
231
  async remove() {
226
- for (const name of ALL_WINDOWS_TASKS) await run('schtasks', ['/Delete', '/F', '/TN', name]).catch(() => {})
232
+ for (const name of await windowsTasks()) await run('schtasks', ['/Delete', '/F', '/TN', name]).catch(() => {})
227
233
  },
228
234
  }
229
235
 
@@ -231,15 +237,15 @@ const SCHEDULERS = { darwin: launchd, linux, win32: windows }
231
237
 
232
238
  // Schedules the background syncs (and the evening nudge) with the system's
233
239
  // own scheduler.
234
- export async function installAutosync(bin, { nudge = true } = {}) {
240
+ export async function installAutosync(bin, { nudge = true, nudgeTime = NUDGE_DEFAULT } = {}) {
235
241
  const scheduler = SCHEDULERS[platform()]
236
242
  const times = SYNC_TIMES.map(clock)
237
- const nudgeAt = nudge ? clock(NUDGE_TIME) : undefined
243
+ const nudgeAt = nudge ? clock(nudgeTime) : undefined
238
244
  if (!scheduler) {
239
245
  return { installed: false, hint: `No scheduler support on ${platform()} yet. Run \`${bin} sync --quiet\` at ${times.join(', ')}${nudge ? ` and \`nudge --quiet\` at ${nudgeAt}` : ''}.` }
240
246
  }
241
247
  await mkdir(configDir(), { recursive: true })
242
- const using = await scheduler.install(process.execPath, await stableBin(bin), nudge)
248
+ const using = await scheduler.install(process.execPath, await stableBin(bin), nudge, nudgeTime)
243
249
  return { installed: true, using, times, nudgeAt, log: logPath() }
244
250
  }
245
251
 
package/src/claude.mjs CHANGED
@@ -38,7 +38,7 @@ export async function collectClaude({ root = join(homedir(), '.claude', 'project
38
38
  return days.get(date)
39
39
  }
40
40
 
41
- for await (const path of walk(root, '.jsonl')) {
41
+ for await (const path of walk(root, '.jsonl', since)) {
42
42
  for await (const line of lines(path)) {
43
43
  const role = ROLE.exec(line)?.[1]
44
44
  if (!role) continue
package/src/codex.mjs CHANGED
@@ -24,7 +24,7 @@ export async function collectCodex({ root = join(homedir(), '.codex'), since } =
24
24
  const seenSessions = new Set()
25
25
 
26
26
  for (const folder of ['sessions', 'archived_sessions']) {
27
- for await (const path of walk(join(root, folder), '.jsonl')) {
27
+ for await (const path of walk(join(root, folder), '.jsonl', since)) {
28
28
  let session
29
29
  let model
30
30
  let last = { input: 0, cached: 0, output: 0 }
package/src/collect.mjs CHANGED
@@ -9,8 +9,19 @@ import { collectOpenCode } from './opencode.mjs'
9
9
 
10
10
  export const PAYLOAD_VERSION = 1
11
11
 
12
- // How far back a sync reaches: a year and a bit, the most a heatmap shows.
13
- export const sinceDate = (days = 400) => dayKey(new Date(Date.now() - days * 864e5))
12
+ // How far back a sync reaches: a machine's first sync sends a year and a
13
+ // bit (the most a heatmap shows); after that the server takes only the last
14
+ // week from it (its history lock), so later syncs send just that.
15
+ export const FULL_DAYS = 400
16
+ export const RECENT_DAYS = 7
17
+ export const sinceDate = (days = FULL_DAYS) => dayKey(new Date(Date.now() - days * 864e5))
18
+
19
+ // Where this machine's next sync starts: a full history the first time (or
20
+ // on --full, or after linking to another server), the last week after that.
21
+ export function syncSince(config, { full = false, since } = {}) {
22
+ if (since) return since
23
+ return full || !config.lastSync || config.lastSyncServer !== config.server ? sinceDate(FULL_DAYS) : sinceDate(RECENT_DAYS)
24
+ }
14
25
 
15
26
  // Everything one machine sends: per-day totals per tool, and its commits as
16
27
  // keyed hashes. No prompt, code, path, repo name or commit message is read
package/src/files.mjs CHANGED
@@ -1,8 +1,10 @@
1
- import { open, readdir } from 'node:fs/promises'
1
+ import { open, readdir, stat } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
 
4
- // Every file under `root` whose name ends in `suffix`, symbolic links not followed.
5
- export async function* walk(root, suffix) {
4
+ // Every file under `root` whose name ends in `suffix`, symbolic links not
5
+ // followed; with `since` (YYYY-MM-DD), only files changed on or after that
6
+ // day (a file untouched since can hold nothing newer).
7
+ export async function* walk(root, suffix, since) {
6
8
  let entries
7
9
  try {
8
10
  entries = await readdir(root, { withFileTypes: true })
@@ -11,11 +13,19 @@ export async function* walk(root, suffix) {
11
13
  }
12
14
  for (const entry of entries) {
13
15
  const path = join(root, entry.name)
14
- if (entry.isDirectory()) yield* walk(path, suffix)
15
- else if (entry.isFile() && entry.name.endsWith(suffix)) yield path
16
+ if (entry.isDirectory()) yield* walk(path, suffix, since)
17
+ else if (entry.isFile() && entry.name.endsWith(suffix)) {
18
+ if (since && (await stat(path).catch(() => undefined))?.mtime < localMidnight(since)) continue
19
+ yield path
20
+ }
16
21
  }
17
22
  }
18
23
 
24
+ const localMidnight = day => {
25
+ const [y, m, d] = day.split('-').map(Number)
26
+ return new Date(y, m - 1, d)
27
+ }
28
+
19
29
  const CHUNK = 4 * 1024 * 1024
20
30
  const NEWLINE = 10
21
31
 
package/src/google.mjs CHANGED
@@ -27,7 +27,7 @@ const dayMap = tool => {
27
27
  // tool calls and tokens (`input` includes the cached part, split out here).
28
28
  export async function collectGeminiCli({ root = join(GEMINI(), 'tmp'), since } = {}) {
29
29
  const { day, list } = dayMap('gemini')
30
- for await (const path of walk(root, '.json')) {
30
+ for await (const path of walk(root, '.json', since)) {
31
31
  if (!/[/\\]chats[/\\]/.test(path)) continue
32
32
  let chat
33
33
  try {
@@ -74,7 +74,7 @@ const seconds = buffer => {
74
74
 
75
75
  export async function collectAntigravity({ root = join(GEMINI(), 'antigravity', 'conversations'), since } = {}) {
76
76
  const { day, list } = dayMap('antigravity')
77
- for await (const path of walk(root, '.db')) {
77
+ for await (const path of walk(root, '.db', since)) {
78
78
  const id = path
79
79
  const steps = await query(path, 'SELECT step_type AS type, hex(metadata) AS meta FROM steps')
80
80
  let lastDate
package/src/nudge.mjs CHANGED
@@ -15,10 +15,52 @@ export function shouldNudge(streak, config) {
15
15
  // --quiet and nothing else it is the scheduled check as 0.1.0 installed it
16
16
  // (`nudge --quiet`), so those jobs keep working until autosync is rerun.
17
17
  export function nudgeAction(rest, values) {
18
- if (['on', 'off', 'test', 'run'].includes(rest[0])) return rest[0]
18
+ if (['on', 'off', 'test', 'run', 'at'].includes(rest[0])) return rest[0]
19
19
  return values.quiet ? 'run' : 'status'
20
20
  }
21
21
 
22
+ // When the evening check may run: late enough that the day is mostly done,
23
+ // early enough to still code before midnight (streaks count calendar days).
24
+ export const NUDGE_DEFAULT = [20, 30]
25
+ export const NUDGE_EARLIEST = [17, 0]
26
+ export const NUDGE_LATEST = [23, 30]
27
+ const minutes = ([h, m]) => h * 60 + m
28
+ const hhmm = ([h, m]) => `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
29
+
30
+ // A time as people type it: 21:45, 2145, 9:45pm, 9pm, 21, or `default`.
31
+ // Returns [hour, minute], or throws a sentence saying what to type instead.
32
+ export function parseNudgeTime(input) {
33
+ const text = String(input ?? '').trim().toLowerCase().replace(/\s+/g, '')
34
+ if (text === 'default') return NUDGE_DEFAULT
35
+ const match = /^(\d{1,2})(?::?(\d{2}))?(am|pm)?$/.exec(text)
36
+ if (!match) throw new Error(`"${input}" is not a time: try 21:45 or 9:45pm.`)
37
+ let hour = Number(match[1])
38
+ const minute = Number(match[2] ?? 0)
39
+ if (match[3]) {
40
+ if (hour < 1 || hour > 12) throw new Error(`"${input}" is not a time: with am/pm the hour is 1 to 12.`)
41
+ hour = (hour % 12) + (match[3] === 'pm' ? 12 : 0)
42
+ }
43
+ if (hour > 23 || minute > 59) throw new Error(`"${input}" is not a time of day.`)
44
+ const time = [hour, minute]
45
+ if (minutes(time) < minutes(NUDGE_EARLIEST) || minutes(time) > minutes(NUDGE_LATEST)) {
46
+ // Small hours (before 5:00) mean 'late at night': the nearest is the latest allowed.
47
+ const nearest = minutes(time) < minutes(NUDGE_EARLIEST) && hour >= 5 ? NUDGE_EARLIEST : NUDGE_LATEST
48
+ throw new Error(
49
+ `The nudge runs between ${hhmm(NUDGE_EARLIEST)} and ${hhmm(NUDGE_LATEST)}: late enough that your day is mostly done, early enough to still code before midnight. The nearest is ${hhmm(nearest)}.`,
50
+ )
51
+ }
52
+ return time
53
+ }
54
+
55
+ // The person's chosen nudge time, or the default.
56
+ export function nudgeTimeOf(config) {
57
+ try {
58
+ return config.nudgeTime ? parseNudgeTime(config.nudgeTime) : NUDGE_DEFAULT
59
+ } catch {
60
+ return NUDGE_DEFAULT
61
+ }
62
+ }
63
+
22
64
  export function nudgeText(streak) {
23
65
  const days = streak.current === 1 ? '1-day streak' : `${streak.current}-day streak`
24
66
  const next = streak.next && streak.current + 1 >= streak.next.days ? ` Today makes it ${streak.next.name}.` : ''
package/src/opencode.mjs CHANGED
@@ -73,7 +73,7 @@ export async function collectOpenCode({ dir = OPENCODE_DIR(), since } = {}) {
73
73
  }
74
74
  return n
75
75
  }
76
- for await (const path of walk(join(storage, 'message'), '.json')) {
76
+ for await (const path of walk(join(storage, 'message'), '.json', since)) {
77
77
  let m
78
78
  try {
79
79
  m = JSON.parse(await readFile(path, 'utf8'))
package/src/view.mjs ADDED
@@ -0,0 +1,138 @@
1
+ import { dayKey } from './days.mjs'
2
+
3
+ // Drawing in the terminal: the site's blue heatmap in coloured squares, its
4
+ // orange for work shipped with AI, plain characters where colour is off.
5
+
6
+ const BLUES = ['#0d3a63', '#1a5fa3', '#2f81f7', '#79c0ff']
7
+ const EMPTY = '#2a323d'
8
+ const SHIP = '#f2994a'
9
+ const MUTED = '#8b949e'
10
+
11
+ // How much colour the terminal takes: none (NO_COLOR, piped output, a dumb
12
+ // terminal), 256 colours, or full colour (COLORTERM says so; Windows Terminal).
13
+ export function colorLevel(stream = process.stdout, env = process.env) {
14
+ if ('NO_COLOR' in env) return 0
15
+ if (env.FORCE_COLOR === '0') return 0
16
+ if (env.FORCE_COLOR) return env.FORCE_COLOR === '1' ? 2 : 3
17
+ if (!stream.isTTY || env.TERM === 'dumb') return 0
18
+ if (/truecolor|24bit/i.test(env.COLORTERM ?? '') || env.WT_SESSION) return 3
19
+ return 2
20
+ }
21
+
22
+ const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
23
+ const to256 = ([r, g, b]) => 16 + 36 * Math.round((r / 255) * 5) + 6 * Math.round((g / 255) * 5) + Math.round((b / 255) * 5)
24
+
25
+ export function painter(level = colorLevel()) {
26
+ const fg = hex => (level === 3 ? `\x1b[38;2;${rgb(hex).join(';')}m` : level === 2 ? `\x1b[38;5;${to256(rgb(hex))}m` : '')
27
+ const wrap = (open, text) => (level ? `${open}${text}\x1b[0m` : text)
28
+ return {
29
+ level,
30
+ color: (hex, text) => wrap(fg(hex), text),
31
+ bold: text => wrap('\x1b[1m', text),
32
+ dim: text => wrap(fg(MUTED), text),
33
+ ship: text => wrap(fg(SHIP), text),
34
+ }
35
+ }
36
+
37
+ // One day's cell: a coloured square by shade (with an orange dot after it
38
+ // when something shipped with AI), or plain characters without colour.
39
+ const PLAIN = ['░', '▒', '▓', '█']
40
+ export function cell(p, shade, shipped) {
41
+ if (shade === null) return ' '
42
+ const mark = shipped ? (p.level ? p.ship('•') : '*') : ' '
43
+ if (p.level === 0) return (shade < 0 ? '·' : PLAIN[shade]) + mark
44
+ return p.color(shade < 0 ? EMPTY : BLUES[shade], '■') + mark
45
+ }
46
+
47
+ const value = d => (d ? (d.messages ?? 0) + (d.aiCommits ?? 0) * 20 : 0)
48
+ const active = d => !!d && ((d.messages ?? 0) > 0 || (d.aiCommits ?? 0) > 0)
49
+
50
+ // Shades by quartile of the person's own active days, as on the site.
51
+ export function shader(days, from, to) {
52
+ const sorted = Object.entries(days)
53
+ .filter(([date, d]) => date >= from && date <= to && active(d))
54
+ .map(([, d]) => value(d))
55
+ .sort((a, b) => a - b)
56
+ const at = q => sorted[Math.floor(q * (sorted.length - 1))] ?? 0
57
+ const cuts = [at(0.25), at(0.5), at(0.75)]
58
+ return d => (!active(d) ? -1 : value(d) <= cuts[0] ? 0 : value(d) <= cuts[1] ? 1 : value(d) <= cuts[2] ? 2 : 3)
59
+ }
60
+
61
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
62
+ const addDays = (key, n) => {
63
+ const [y, m, d] = key.split('-').map(Number)
64
+ return dayKey(new Date(y, m - 1, d + n))
65
+ }
66
+ const weekday = key => {
67
+ const [y, m, d] = key.split('-').map(Number)
68
+ return new Date(y, m - 1, d).getDay()
69
+ }
70
+
71
+ // The heatmap: Sunday-first columns from the first active day (or as many
72
+ // weeks as fit), month names above, Mon/Wed/Fri down the side.
73
+ export function heatmap(p, days, today, { columns = 80, first } = {}) {
74
+ const maxWeeks = Math.max(4, Math.floor((columns - 5) / 2))
75
+ const earliest = addDays(today, -(maxWeeks * 7 - 1) - weekday(today))
76
+ const from = first && first > earliest ? first : earliest
77
+ const start = addDays(from, -weekday(from))
78
+ const weeks = []
79
+ for (let day = start; day <= today; day = addDays(day, 7)) weeks.push(Array.from({ length: 7 }, (_, i) => addDays(day, i)))
80
+ const shade = shader(days, from, today)
81
+ // A month's name sits over its first week. When it would collide with the
82
+ // previous name, the previous one (a month shown for a week at most) gives way.
83
+ const labels = []
84
+ let lastMonth = -1
85
+ weeks.forEach((week, w) => {
86
+ const shown = week.find(d => d >= from) ?? week[0]
87
+ const month = Number(shown.slice(5, 7)) - 1
88
+ if (month !== lastMonth && w <= weeks.length - 2) {
89
+ const previous = labels.at(-1)
90
+ if (previous && w - previous.w < 2) labels.pop()
91
+ labels.push({ w, name: MONTHS[month] })
92
+ }
93
+ lastMonth = month
94
+ })
95
+ let header = ' '
96
+ for (const { w, name } of labels) if (header.length <= 5 + w * 2) header = header.padEnd(5 + w * 2) + name
97
+ const rows = ['', 'Mon', '', 'Wed', '', 'Fri', ''].map((label, d) => {
98
+ const cells = weeks.map(week => {
99
+ const date = week[d]
100
+ if (date < from || date > today) return ' '
101
+ const day = days[date]
102
+ return cell(p, shade(day), (day?.aiCommits ?? 0) > 0)
103
+ })
104
+ return `${p.dim(label.padEnd(4))} ${cells.join('')}`
105
+ })
106
+ const legend = ` ${p.dim('less')} ${[-1, 0, 1, 2, 3].map(s => cell(p, s, false)).join('')}${p.dim('more')} ${p.level ? p.ship('•') : '*'} ${p.dim('shipped with AI')}`
107
+ return [p.dim(header), ...rows, legend].join('\n')
108
+ }
109
+
110
+ // The last four weeks in one line, from a board row's 12-week strip.
111
+ export function recent(p, strip, length = 28) {
112
+ return strip
113
+ .flat()
114
+ .filter(s => s !== -2)
115
+ .slice(-length)
116
+ .map(s => cell(p, s, false).trimEnd() || ' ')
117
+ .join('')
118
+ }
119
+
120
+ export const compact = n =>
121
+ n >= 1e12 ? `${(n / 1e12).toFixed(1)}T` : n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e4 ? `${(n / 1e3).toFixed(1)}K` : n.toLocaleString('en-US')
122
+
123
+ export const TOOL_NAMES = {
124
+ claude: 'Claude Code',
125
+ codex: 'Codex',
126
+ cursor: 'Cursor',
127
+ copilot: 'Copilot',
128
+ gemini: 'Gemini CLI',
129
+ antigravity: 'Antigravity',
130
+ opencode: 'OpenCode',
131
+ }
132
+
133
+ // Pads text that may hold colour codes to a visible width.
134
+ export const visible = text => text.replace(/\x1b\[[0-9;]*m/g, '')
135
+ export const pad = (text, width, right = false) => {
136
+ const gap = ' '.repeat(Math.max(0, width - [...visible(text)].length))
137
+ return right ? gap + text : text + gap
138
+ }
package/src/views.mjs ADDED
@@ -0,0 +1,130 @@
1
+ import { compact, heatmap, pad, painter, recent, TOOL_NAMES } from './view.mjs'
2
+
3
+ // The read-only commands: your profile and others', the board, today, and
4
+ // the dashboard plain `vibestreak` shows. Each fetches the public API (with
5
+ // the machine's token where it has one, so a hidden profile's owner still
6
+ // sees it) and draws for the terminal's width.
7
+
8
+ const fmt = n => Number(n ?? 0).toLocaleString('en-US')
9
+ const plural = (n, one, many = `${one}s`) => `${fmt(n)} ${n === 1 ? one : many}`
10
+ const toolList = tools => (tools ?? []).map(t => TOOL_NAMES[t] ?? t).join(', ')
11
+
12
+ const PERIODS = ['today', '7d', '30d', '90d', 'all']
13
+ const METRICS = ['consistency', 'shipped', 'streak', 'tokens']
14
+ const TOOLS = Object.keys(TOOL_NAMES)
15
+
16
+ async function getJson(server, path, token) {
17
+ const response = await fetch(new URL(path, server), { headers: token ? { authorization: `Bearer ${token}` } : {} })
18
+ const body = await response.json().catch(() => ({}))
19
+ if (response.status === 404) throw new Error('No such public profile.')
20
+ if (!response.ok) throw new Error(body.error ?? `${response.status} ${response.statusText}`)
21
+ return body
22
+ }
23
+
24
+ function streakLine(p, profile) {
25
+ const s = profile.summary.streak
26
+ const today = profile.activeToday ? p.color('#3fb950', '✓ active today') : s.current > 0 ? p.ship('streak ends at midnight') : p.dim('no live streak')
27
+ const level = profile.level ? `${p.color('#2f81f7', '◆')} ${profile.level}` : ''
28
+ const next = profile.next ? p.dim(` · ${plural(profile.next.remaining, 'day')} to ${profile.next.name}`) : ''
29
+ return `${p.bold(`${fmt(s.current)}-day streak`)} ${p.dim(`(best ${fmt(s.longest)})`)} ${today} ${level}${next}`
30
+ }
31
+
32
+ // `vibestreak me` and `vibestreak profile <handle>`.
33
+ export async function profileView({ server, token, handle, columns }) {
34
+ const p = painter()
35
+ const profile = await getJson(server, `/api/v1/users/${encodeURIComponent(handle)}.json`, token)
36
+ const s = profile.summary
37
+ const out = [
38
+ `${p.bold(`@${profile.handle}`)} ${p.dim(toolList(profile.tools))}`,
39
+ streakLine(p, profile),
40
+ '',
41
+ heatmap(p, profile.days, profile.today, { columns, first: s.first }),
42
+ '',
43
+ `${p.bold(fmt(s.activeDays))} active days${s.first ? p.dim(` since ${s.first}`) : ''} ${p.ship(fmt(s.aiCommits))} commits shipped with AI ${p.dim(`on ${fmt(s.shippedDays)} days`)}`,
44
+ `${fmt(s.messages)} messages · ${fmt(s.toolCalls)} tool calls · ${compact(s.input + s.output + s.cacheRead + s.cacheWrite)} tokens${s.busiest ? p.dim(` busiest day ${s.busiest.date}, ${fmt(s.busiest.messages)} messages`) : ''}`,
45
+ p.dim(profile.profileUrl),
46
+ ]
47
+ console.log(out.join('\n'))
48
+ }
49
+
50
+ // Board options from words or flags: `board 7d shipped claude`, `--from … --to …`.
51
+ export function boardQuery(words, values) {
52
+ const params = new URLSearchParams()
53
+ for (const word of words.map(w => w.toLowerCase())) {
54
+ if (PERIODS.includes(word)) params.set('period', word)
55
+ else if (METRICS.includes(word)) params.set('metric', word)
56
+ else if (TOOLS.includes(word)) params.set('tool', word)
57
+ else throw new Error(`"${word}" is not a period (${PERIODS.join(', ')}), a ranking (${METRICS.join(', ')}) or a tool (${TOOLS.join(', ')}).`)
58
+ }
59
+ if (values.from || values.to) {
60
+ if (!values.from || !values.to) throw new Error('A custom range needs both --from and --to (YYYY-MM-DD).')
61
+ params.set('from', values.from)
62
+ params.set('to', values.to)
63
+ params.delete('period')
64
+ }
65
+ return params
66
+ }
67
+
68
+ const METRIC_LABEL = { consistency: 'active days', shipped: 'commits shipped with AI', streak: 'current streak', tokens: 'tokens' }
69
+
70
+ // `vibestreak board`: the top ten, a four-week strip per person, and your
71
+ // own row pinned below when you rank lower.
72
+ export async function boardView({ server, words, values, you, columns }) {
73
+ const p = painter()
74
+ const params = boardQuery(words, values)
75
+ params.set('limit', String(values.limit ?? 10))
76
+ if (you) params.set('you', you)
77
+ const b = await getJson(server, `/api/v1/board?${params}`)
78
+ const period = b.range ? `${b.range.from} → ${b.range.to}` : { today: 'today', all: 'all time' }[b.period] ?? `last ${b.period}`
79
+ console.log(`${p.bold('Leaderboard')} ${p.dim(`· ${METRIC_LABEL[b.metric]} · ${period}${b.tool ? ` · ${TOOL_NAMES[b.tool]}` : ''} · ${plural(b.total, 'developer')}`)}`)
80
+ if (!b.rows.length) return console.log(p.dim('Nobody here yet for these days.'))
81
+ const wide = columns >= 96
82
+ const handleWidth = Math.min(22, Math.max(8, ...b.rows.map(r => r.handle.length + 1)))
83
+ const header = [pad('#', 4), pad('developer', handleWidth + 1), wide ? pad('last 4 weeks', 29) : '', pad('active', 7, true), pad('streak', 8, true), pad('shipped', 9, true), pad('tokens', 8, true)].join(' ')
84
+ console.log(p.dim(header))
85
+ const line = r => {
86
+ const me = you && r.handle.toLowerCase() === you.toLowerCase()
87
+ const name = pad(`@${r.handle}`, handleWidth + 1)
88
+ return [
89
+ pad(String(r.rank), 4),
90
+ me ? p.bold(name) : name,
91
+ wide ? pad(recent(p, r.strip), 29) : '',
92
+ pad(fmt(r.activeDays), 7, true),
93
+ pad(`${fmt(r.streak)}d`, 8, true),
94
+ pad(p.ship(fmt(r.aiCommits)), 9, true),
95
+ pad(compact(r.tokens), 8, true),
96
+ ].join(' ') + (me ? p.dim(' ← you') : '')
97
+ }
98
+ for (const r of b.rows) console.log(line(r))
99
+ if (b.you && b.you.rank > b.rows.length) console.log(`${p.dim(' …')}\n${line(b.you)}`)
100
+ console.log(p.dim(`\nMore: vibestreak board [today|7d|30d|90d|all] [consistency|shipped|streak|tokens] [tool] · ${new URL('/', server)}`))
101
+ }
102
+
103
+ // `vibestreak today`: who is coding with AI today, and whose streak ends at midnight.
104
+ export async function todayView({ server }) {
105
+ const p = painter()
106
+ const t = await getJson(server, '/api/v1/today')
107
+ console.log(`${p.color('#3fb950', '●')} ${p.bold(plural(t.active.length, 'developer'))} coding with AI today ${p.dim(`(of ${fmt(t.developers)})`)}`)
108
+ for (const r of t.active.slice(0, 15)) console.log(` @${pad(r.handle, 22)} ${pad(`${fmt(r.streak)}d`, 6, true)} ${p.dim(toolList(r.tools))}`)
109
+ if (t.atRisk.length) {
110
+ console.log(`\n${p.ship(`${plural(t.atRisk.length, 'streak')} at risk`)} ${p.dim('· ends at their midnight')}`)
111
+ for (const r of t.atRisk.slice(0, 10)) console.log(` @${pad(r.handle, 22)} ${pad(p.ship(`${fmt(r.streak)}d`), 6, true)}`)
112
+ }
113
+ }
114
+
115
+ // Plain `vibestreak`: your streak, the last twelve weeks, your rank, and
116
+ // what to run next.
117
+ export async function dashboardView({ server, token, handle, columns }) {
118
+ const p = painter()
119
+ const [profile, b] = await Promise.all([
120
+ getJson(server, `/api/v1/users/${encodeURIComponent(handle)}.json`, token),
121
+ getJson(server, `/api/v1/board?limit=0&you=${encodeURIComponent(handle)}`).catch(() => null),
122
+ ])
123
+ const rank = b?.you ? `#${b.you.rank} of ${fmt(b.total)} ${p.dim('· 30 days, consistency')}` : p.dim('not on the public board')
124
+ console.log(`${p.bold(`@${profile.handle}`)} ${rank}`)
125
+ console.log(streakLine(p, profile))
126
+ console.log('')
127
+ console.log(heatmap(p, profile.days, profile.today, { columns: Math.min(columns, 31), first: profile.summary.first }))
128
+ console.log('')
129
+ console.log(p.dim('vibestreak me · board · today · status · help'))
130
+ }