vibestreak 0.1.2 → 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.
- package/bin/vibestreak.mjs +39 -3
- package/package.json +1 -1
- package/src/claude.mjs +1 -1
- package/src/codex.mjs +1 -1
- package/src/collect.mjs +13 -2
- package/src/files.mjs +15 -5
- package/src/google.mjs +2 -2
- package/src/opencode.mjs +1 -1
- package/src/view.mjs +138 -0
- package/src/views.mjs +130 -0
package/bin/vibestreak.mjs
CHANGED
|
@@ -4,15 +4,23 @@ import { parseArgs } from 'node:util'
|
|
|
4
4
|
|
|
5
5
|
import { autosyncStatus, clock, installAutosync, nextRun, removeAutosync, SYNC_TIMES } from '../src/autosync.mjs'
|
|
6
6
|
import { NUDGE_DEFAULT, notify, nudgeAction, nudgeText, nudgeTimeOf, parseNudgeTime, shouldNudge } from '../src/nudge.mjs'
|
|
7
|
-
import { collect, sinceDate, summarize } from '../src/collect.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)
|
|
@@ -58,14 +66,40 @@ async function main() {
|
|
|
58
66
|
token: { type: 'string' },
|
|
59
67
|
server: { type: 'string' },
|
|
60
68
|
since: { type: 'string' },
|
|
69
|
+
full: { type: 'boolean' },
|
|
61
70
|
help: { type: 'boolean', short: 'h' },
|
|
71
|
+
from: { type: 'string' },
|
|
72
|
+
to: { type: 'string' },
|
|
73
|
+
limit: { type: 'string' },
|
|
62
74
|
},
|
|
63
75
|
})
|
|
64
|
-
const [command = '
|
|
76
|
+
const [command = 'dashboard', ...rest] = positionals
|
|
65
77
|
const config = await loadConfig()
|
|
66
78
|
const log = (...args) => values.quiet || console.log(...args)
|
|
67
79
|
|
|
68
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 })
|
|
69
103
|
|
|
70
104
|
if (command === 'emails') {
|
|
71
105
|
const [action, email] = rest
|
|
@@ -186,7 +220,7 @@ async function sync(config, values, log, isSync) {
|
|
|
186
220
|
if (first) emails.push(first)
|
|
187
221
|
}
|
|
188
222
|
const started = Date.now()
|
|
189
|
-
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 })
|
|
190
224
|
if (values.json) return console.log(JSON.stringify(payload, null, 2))
|
|
191
225
|
const s = summarize(payload)
|
|
192
226
|
log(`${s.activeDays} active days since ${payload.since} (${((Date.now() - started) / 1000).toFixed(1)}s)`)
|
|
@@ -197,7 +231,9 @@ async function sync(config, values, log, isSync) {
|
|
|
197
231
|
if (!isSync) return log(`\nNothing was sent. \`${BRAND} sync\` uploads these totals.`)
|
|
198
232
|
const result = await api(config, '/api/v1/upload', { method: 'POST', body: JSON.stringify(payload) })
|
|
199
233
|
config.lastSync = new Date().toISOString()
|
|
234
|
+
config.lastSyncServer = config.server
|
|
200
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)}`)
|
|
201
237
|
await saveConfig(config)
|
|
202
238
|
// Always one line, quiet or not: the background sync's log is how a person sees it ran.
|
|
203
239
|
const streak = result.streak ? ` · streak ${result.streak.current}d${result.streak.activeToday ? '' : ' (not yet today)'}` : ''
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibestreak",
|
|
3
|
-
"version": "0.
|
|
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/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
|
|
13
|
-
|
|
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
|
|
5
|
-
|
|
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))
|
|
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/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
|
+
}
|