vibestreak 0.1.0 → 0.1.2
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 +82 -10
- package/package.json +1 -1
- package/src/autosync.mjs +57 -18
- package/src/nudge.mjs +50 -0
package/bin/vibestreak.mjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { parseArgs } from 'node:util'
|
|
4
4
|
|
|
5
|
-
import { installAutosync, removeAutosync } from '../src/autosync.mjs'
|
|
6
|
-
import { notify, nudgeText, shouldNudge } from '../src/nudge.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
7
|
import { collect, sinceDate, summarize } 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'
|
|
@@ -21,9 +21,11 @@ const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and h
|
|
|
21
21
|
the addresses you commit as (all of your accounts)
|
|
22
22
|
${BRAND} roots [add <dir>|remove <dir>]
|
|
23
23
|
where to look for git repos
|
|
24
|
+
${BRAND} status profile, last sync, streak, autosync and nudge
|
|
24
25
|
${BRAND} autosync [on|off] sync in the background through the day
|
|
25
26
|
${BRAND} nudge [on|off|test] an evening notification when your streak is
|
|
26
|
-
about to end (on with autosync)
|
|
27
|
+
about to end (on with autosync); alone, its status
|
|
28
|
+
${BRAND} nudge at 21:45 when it checks (17:00 to 23:30; default 20:30)
|
|
27
29
|
${BRAND} whoami the profile this machine syncs to
|
|
28
30
|
|
|
29
31
|
Reads: Claude Code, Codex, Cursor, Copilot (CLI and VS Code), Gemini CLI,
|
|
@@ -116,20 +118,34 @@ async function main() {
|
|
|
116
118
|
|
|
117
119
|
if (command === 'collect' || command === 'sync') return sync(config, values, log, command === 'sync')
|
|
118
120
|
|
|
121
|
+
if (command === 'status') return status(config)
|
|
122
|
+
|
|
119
123
|
if (command === 'nudge') {
|
|
120
124
|
const bin = fileURLToPath(import.meta.url)
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
const action = nudgeAction(rest, values)
|
|
126
|
+
if (action === 'status') return nudgeStatus(config)
|
|
127
|
+
if (action === 'at') {
|
|
128
|
+
const time = parseNudgeTime(rest[1])
|
|
129
|
+
config.nudgeTime = clock(time)
|
|
130
|
+
if (rest[1].trim().toLowerCase() === 'default') delete config.nudgeTime
|
|
131
|
+
await saveConfig(config)
|
|
132
|
+
const scheduled = await autosyncStatus()
|
|
133
|
+
if (scheduled.on && config.nudge !== false) await installAutosync(bin, { nudge: true, nudgeTime: time })
|
|
134
|
+
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\`.` : ''
|
|
135
|
+
return console.log(`Streak check at ${clock(time)}${clock(time) === clock(NUDGE_DEFAULT) ? ' (the default)' : ''}.${note}`)
|
|
136
|
+
}
|
|
137
|
+
if (action === 'on' || action === 'off') {
|
|
138
|
+
config.nudge = action === 'on'
|
|
123
139
|
await saveConfig(config)
|
|
124
|
-
if (config.autosync) await installAutosync(bin, { nudge: config.nudge })
|
|
125
|
-
return console.log(`Streak nudge ${config.nudge ?
|
|
140
|
+
if (config.autosync) await installAutosync(bin, { nudge: config.nudge, nudgeTime: nudgeTimeOf(config) })
|
|
141
|
+
return console.log(`Streak nudge ${config.nudge ? `on: ${clock(nudgeTimeOf(config))} each evening when your streak is at risk` : 'off'}.`)
|
|
126
142
|
}
|
|
127
|
-
if (
|
|
143
|
+
if (action === 'test') {
|
|
128
144
|
const { streak } = await api(config, '/api/v1/me')
|
|
129
145
|
await notify(nudgeText({ ...streak, current: Math.max(1, streak.current) }))
|
|
130
146
|
return console.log(`Sent a test notification (your streak: ${streak.current} days; ${streak.activeToday ? 'active today' : 'not active yet today'}).`)
|
|
131
147
|
}
|
|
132
|
-
//
|
|
148
|
+
// `nudge run`, the evening job: sync, then nudge if the streak would end at midnight.
|
|
133
149
|
const result = await sync(config, values, log, true)
|
|
134
150
|
if (shouldNudge(result.streak, config)) {
|
|
135
151
|
await notify(nudgeText(result.streak))
|
|
@@ -146,7 +162,7 @@ async function main() {
|
|
|
146
162
|
await saveConfig(config)
|
|
147
163
|
return console.log(await removeAutosync())
|
|
148
164
|
}
|
|
149
|
-
const result = await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false })
|
|
165
|
+
const result = await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false, nudgeTime: nudgeTimeOf(config) })
|
|
150
166
|
config.autosync = result.installed
|
|
151
167
|
await saveConfig(config)
|
|
152
168
|
return console.log(
|
|
@@ -181,6 +197,7 @@ async function sync(config, values, log, isSync) {
|
|
|
181
197
|
if (!isSync) return log(`\nNothing was sent. \`${BRAND} sync\` uploads these totals.`)
|
|
182
198
|
const result = await api(config, '/api/v1/upload', { method: 'POST', body: JSON.stringify(payload) })
|
|
183
199
|
config.lastSync = new Date().toISOString()
|
|
200
|
+
config.lastSyncSummary = { days: result.days, commits: result.commits, repos: s.repos }
|
|
184
201
|
await saveConfig(config)
|
|
185
202
|
// Always one line, quiet or not: the background sync's log is how a person sees it ran.
|
|
186
203
|
const streak = result.streak ? ` · streak ${result.streak.current}d${result.streak.activeToday ? '' : ' (not yet today)'}` : ''
|
|
@@ -188,6 +205,61 @@ async function sync(config, values, log, isSync) {
|
|
|
188
205
|
return result
|
|
189
206
|
}
|
|
190
207
|
|
|
208
|
+
const ago = iso => {
|
|
209
|
+
if (!iso) return 'never'
|
|
210
|
+
const minutes = Math.round((Date.now() - Date.parse(iso)) / 60000)
|
|
211
|
+
return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : minutes < 48 * 60 ? `${Math.round(minutes / 60)} h ago` : `${Math.round(minutes / 1440)} days ago`
|
|
212
|
+
}
|
|
213
|
+
const at = ({ at, day }) => `${day} ${clock([at.getHours(), at.getMinutes()])}`
|
|
214
|
+
|
|
215
|
+
// The server's view of the streak, or why it can't be had (not linked, offline).
|
|
216
|
+
async function serverStatus(config) {
|
|
217
|
+
if (!config.server || !config.token) return { error: `not linked: run \`${BRAND} login --token PASTE_YOUR_TOKEN\`` }
|
|
218
|
+
try {
|
|
219
|
+
return await api(config, '/api/v1/me')
|
|
220
|
+
} catch (error) {
|
|
221
|
+
return { error: error.message }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function streakLine(streak) {
|
|
226
|
+
if (!streak) return 'unknown'
|
|
227
|
+
const today = streak.activeToday ? 'active today' : streak.current > 0 ? 'not yet today: ends at midnight' : 'no live streak'
|
|
228
|
+
const next = streak.next ? ` · ${streak.next.remaining} ${streak.next.remaining === 1 ? 'day' : 'days'} to ${streak.next.name}` : ''
|
|
229
|
+
return `${streak.current} ${streak.current === 1 ? 'day' : 'days'} · ${today}${next}`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function nudgeStatus(config) {
|
|
233
|
+
const [scheduled, me] = await Promise.all([autosyncStatus(), serverStatus(config)])
|
|
234
|
+
const on = config.nudge !== false && scheduled.nudge
|
|
235
|
+
const why = config.nudge === false ? ` (\`${BRAND} nudge on\` turns it on)` : !scheduled.on ? ` (needs autosync: \`${BRAND} autosync on\`)` : ''
|
|
236
|
+
const time = clock(nudgeTimeOf(config))
|
|
237
|
+
console.log(`Nudge ${on ? `on · checks at ${time}${config.nudgeTime ? '' : ' (default; `nudge at 21:45` changes it)'}` : `off${why}`}`)
|
|
238
|
+
console.log(`Last nudge ${config.lastNudge ?? 'never'}`)
|
|
239
|
+
if (me.error) return console.log(`Tonight unknown (${me.error})`)
|
|
240
|
+
const s = me.streak
|
|
241
|
+
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'
|
|
242
|
+
console.log(`Streak ${streakLine(s)}`)
|
|
243
|
+
console.log(`Tonight ${tonight}`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function status(config) {
|
|
247
|
+
const [scheduled, me] = await Promise.all([autosyncStatus(), serverStatus(config)])
|
|
248
|
+
const row = (label, value) => console.log(`${label.padEnd(11)} ${value}`)
|
|
249
|
+
row('Profile', me.error ? me.error : `@${me.handle} · ${me.profileUrl}`)
|
|
250
|
+
if (!me.error && me.github?.length) row('GitHub', me.github.map(g => `github.com/${g}`).join(', '))
|
|
251
|
+
row('Machine', config.machineName)
|
|
252
|
+
const last = config.lastSyncSummary
|
|
253
|
+
row('Last sync', config.lastSync ? `${ago(config.lastSync)}${last ? ` · ${last.days} days, ${last.commits} commits from ${last.repos} repos` : ''}` : `never: run \`${BRAND} sync\``)
|
|
254
|
+
if (!me.error) row('Streak', streakLine(me.streak))
|
|
255
|
+
row('Autosync', scheduled.on ? `on (${scheduled.using}) · next sync ${at(nextRun(SYNC_TIMES))}` : `off: \`${BRAND} autosync on\` syncs at ${SYNC_TIMES.map(clock).join(', ')}`)
|
|
256
|
+
const nudgeOn = config.nudge !== false && scheduled.nudge
|
|
257
|
+
row('Nudge', nudgeOn ? `on · checks at ${clock(nudgeTimeOf(config))} · last nudged ${config.lastNudge ?? 'never'}` : config.nudge === false ? 'off' : 'off (needs autosync)')
|
|
258
|
+
const github = me.error ? 0 : (me.emails?.length ?? 0)
|
|
259
|
+
row('Addresses', `${config.emails.join(', ') || 'none'}${github ? ` · +${github} from GitHub` : ''}`)
|
|
260
|
+
row('Config', CONFIG_PATH)
|
|
261
|
+
}
|
|
262
|
+
|
|
191
263
|
main().catch(error => {
|
|
192
264
|
console.error(`${BRAND}: ${error.message}`)
|
|
193
265
|
process.exitCode = 1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibestreak",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
2
3
|
import { cp, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
3
4
|
import { homedir, platform } from 'node:os'
|
|
4
5
|
import { dirname, join } from 'node:path'
|
|
5
6
|
import { promisify } from 'node:util'
|
|
6
7
|
|
|
7
8
|
import { CONFIG_PATH } from './config.mjs'
|
|
9
|
+
import { NUDGE_DEFAULT } from './nudge.mjs'
|
|
8
10
|
|
|
9
11
|
const run = promisify(execFile)
|
|
10
12
|
|
|
@@ -15,16 +17,17 @@ export const SYNC_TIMES = [
|
|
|
15
17
|
[13, 17],
|
|
16
18
|
[17, 17],
|
|
17
19
|
]
|
|
18
|
-
export
|
|
20
|
+
export { NUDGE_DEFAULT as NUDGE_TIME }
|
|
19
21
|
|
|
20
22
|
export const clock = ([h, m]) => `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
|
21
23
|
const configDir = () => dirname(CONFIG_PATH)
|
|
22
24
|
const logPath = () => join(configDir(), 'sync.log')
|
|
23
25
|
|
|
24
|
-
// The jobs to schedule: a sync at each sync time, and the evening nudge
|
|
25
|
-
|
|
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) => [
|
|
26
29
|
{ name: 'sync', args: ['sync', '--quiet'], times: SYNC_TIMES },
|
|
27
|
-
...(nudge ? [{ name: 'nudge', args: ['nudge', '--quiet'], times: [
|
|
30
|
+
...(nudge ? [{ name: 'nudge', args: ['nudge', 'run', '--quiet'], times: [nudgeTime] }] : []),
|
|
28
31
|
]
|
|
29
32
|
|
|
30
33
|
// A CLI run through npx lives in npm's cache, which can be cleared; a
|
|
@@ -64,9 +67,9 @@ export function launchdPlist(label, node, bin, args, times, log) {
|
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
const launchd = {
|
|
67
|
-
async install(node, bin, nudge) {
|
|
70
|
+
async install(node, bin, nudge, nudgeTime) {
|
|
68
71
|
await mkdir(agents(), { recursive: true })
|
|
69
|
-
for (const job of jobs(true)) {
|
|
72
|
+
for (const job of jobs(true, nudgeTime)) {
|
|
70
73
|
const plist = join(agents(), `${LAUNCHD[job.name]}.plist`)
|
|
71
74
|
await run('launchctl', ['unload', plist]).catch(() => {})
|
|
72
75
|
if (job.name === 'nudge' && !nudge) {
|
|
@@ -78,6 +81,10 @@ const launchd = {
|
|
|
78
81
|
}
|
|
79
82
|
return 'launchd'
|
|
80
83
|
},
|
|
84
|
+
async status() {
|
|
85
|
+
const has = name => existsSync(join(agents(), `${LAUNCHD[name]}.plist`))
|
|
86
|
+
return { on: has('sync'), using: 'launchd', nudge: has('nudge') }
|
|
87
|
+
},
|
|
81
88
|
async remove() {
|
|
82
89
|
for (const label of Object.values(LAUNCHD)) {
|
|
83
90
|
const plist = join(agents(), `${label}.plist`)
|
|
@@ -124,8 +131,8 @@ const CRON_BEGIN = '# vibestreak:begin'
|
|
|
124
131
|
const CRON_END = '# vibestreak:end'
|
|
125
132
|
const shellQuote = a => `'${a.replace(/'/g, `'\\''`)}'`
|
|
126
133
|
|
|
127
|
-
export function cronBlock(node, bin, nudge, log) {
|
|
128
|
-
const lines = jobs(nudge).flatMap(job =>
|
|
134
|
+
export function cronBlock(node, bin, nudge, log, nudgeTime) {
|
|
135
|
+
const lines = jobs(nudge, nudgeTime).flatMap(job =>
|
|
129
136
|
job.times.map(([h, m]) => `${m} ${h} * * * ${[node, bin, ...job.args].map(shellQuote).join(' ')} >> ${shellQuote(log)} 2>&1`),
|
|
130
137
|
)
|
|
131
138
|
return [CRON_BEGIN, ...lines, CRON_END].join('\n')
|
|
@@ -148,10 +155,10 @@ async function writeCrontab(block) {
|
|
|
148
155
|
const hasUserSystemd = () => run('systemctl', ['--user', 'show-environment']).then(() => true, () => false)
|
|
149
156
|
|
|
150
157
|
const linux = {
|
|
151
|
-
async install(node, bin, nudge) {
|
|
158
|
+
async install(node, bin, nudge, nudgeTime) {
|
|
152
159
|
if (await hasUserSystemd()) {
|
|
153
160
|
await mkdir(units(), { recursive: true })
|
|
154
|
-
for (const job of jobs(true)) {
|
|
161
|
+
for (const job of jobs(true, nudgeTime)) {
|
|
155
162
|
const name = `vibestreak-${job.name}`
|
|
156
163
|
if (job.name === 'nudge' && !nudge) {
|
|
157
164
|
await run('systemctl', ['--user', 'disable', '--now', `${name}.timer`]).catch(() => {})
|
|
@@ -167,9 +174,17 @@ const linux = {
|
|
|
167
174
|
for (const job of jobs(nudge)) await run('systemctl', ['--user', 'enable', '--now', `vibestreak-${job.name}.timer`])
|
|
168
175
|
return 'systemd'
|
|
169
176
|
}
|
|
170
|
-
await writeCrontab(cronBlock(node, bin, nudge, logPath()))
|
|
177
|
+
await writeCrontab(cronBlock(node, bin, nudge, logPath(), nudgeTime))
|
|
171
178
|
return 'crontab'
|
|
172
179
|
},
|
|
180
|
+
async status() {
|
|
181
|
+
if (existsSync(join(units(), 'vibestreak-sync.timer'))) {
|
|
182
|
+
return { on: true, using: 'systemd', nudge: existsSync(join(units(), 'vibestreak-nudge.timer')) }
|
|
183
|
+
}
|
|
184
|
+
const cron = await run('crontab', ['-l']).then(r => r.stdout, () => '')
|
|
185
|
+
const block = cron.match(new RegExp(`${CRON_BEGIN}[\\s\\S]*?${CRON_END}`))?.[0] ?? ''
|
|
186
|
+
return { on: /\bsync\b/.test(block), using: 'crontab', nudge: /\bnudge\b/.test(block) }
|
|
187
|
+
},
|
|
173
188
|
async remove() {
|
|
174
189
|
for (const name of ['vibestreak-sync', 'vibestreak-nudge']) {
|
|
175
190
|
await run('systemctl', ['--user', 'disable', '--now', `${name}.timer`]).catch(() => {})
|
|
@@ -189,13 +204,18 @@ export const windowsTaskName = (job, [h, m]) => `vibestreak\\${job.name}-${Strin
|
|
|
189
204
|
const cmdPath = p => p.replace(/%/g, '%%')
|
|
190
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`
|
|
191
206
|
|
|
192
|
-
|
|
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
|
+
}
|
|
193
213
|
|
|
194
214
|
const windows = {
|
|
195
|
-
async install(node, bin, nudge) {
|
|
215
|
+
async install(node, bin, nudge, nudgeTime) {
|
|
196
216
|
await windows.remove()
|
|
197
217
|
await mkdir(configDir(), { recursive: true })
|
|
198
|
-
for (const job of jobs(nudge)) {
|
|
218
|
+
for (const job of jobs(nudge, nudgeTime)) {
|
|
199
219
|
const script = join(configDir(), `${job.name}.cmd`)
|
|
200
220
|
await writeFile(script, windowsScript(node, bin, job, logPath()))
|
|
201
221
|
for (const time of job.times) {
|
|
@@ -204,8 +224,12 @@ const windows = {
|
|
|
204
224
|
}
|
|
205
225
|
return 'Task Scheduler'
|
|
206
226
|
},
|
|
227
|
+
async status() {
|
|
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)) }
|
|
230
|
+
},
|
|
207
231
|
async remove() {
|
|
208
|
-
for (const name of
|
|
232
|
+
for (const name of await windowsTasks()) await run('schtasks', ['/Delete', '/F', '/TN', name]).catch(() => {})
|
|
209
233
|
},
|
|
210
234
|
}
|
|
211
235
|
|
|
@@ -213,18 +237,33 @@ const SCHEDULERS = { darwin: launchd, linux, win32: windows }
|
|
|
213
237
|
|
|
214
238
|
// Schedules the background syncs (and the evening nudge) with the system's
|
|
215
239
|
// own scheduler.
|
|
216
|
-
export async function installAutosync(bin, { nudge = true } = {}) {
|
|
240
|
+
export async function installAutosync(bin, { nudge = true, nudgeTime = NUDGE_DEFAULT } = {}) {
|
|
217
241
|
const scheduler = SCHEDULERS[platform()]
|
|
218
242
|
const times = SYNC_TIMES.map(clock)
|
|
219
|
-
const nudgeAt = nudge ? clock(
|
|
243
|
+
const nudgeAt = nudge ? clock(nudgeTime) : undefined
|
|
220
244
|
if (!scheduler) {
|
|
221
245
|
return { installed: false, hint: `No scheduler support on ${platform()} yet. Run \`${bin} sync --quiet\` at ${times.join(', ')}${nudge ? ` and \`nudge --quiet\` at ${nudgeAt}` : ''}.` }
|
|
222
246
|
}
|
|
223
247
|
await mkdir(configDir(), { recursive: true })
|
|
224
|
-
const using = await scheduler.install(process.execPath, await stableBin(bin), nudge)
|
|
248
|
+
const using = await scheduler.install(process.execPath, await stableBin(bin), nudge, nudgeTime)
|
|
225
249
|
return { installed: true, using, times, nudgeAt, log: logPath() }
|
|
226
250
|
}
|
|
227
251
|
|
|
252
|
+
// What the system's scheduler holds now: whether the syncs and the nudge are scheduled.
|
|
253
|
+
export async function autosyncStatus() {
|
|
254
|
+
const scheduler = SCHEDULERS[platform()]
|
|
255
|
+
return scheduler ? scheduler.status() : { on: false, using: undefined, nudge: false }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The next of the given daily times after `now`: today if one is still ahead, else tomorrow's first.
|
|
259
|
+
export function nextRun(times, now = new Date()) {
|
|
260
|
+
const today = times.map(([h, m]) => new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m)).sort((a, b) => a - b)
|
|
261
|
+
const later = today.find(t => t > now)
|
|
262
|
+
if (later) return { at: later, day: 'today' }
|
|
263
|
+
const [first] = today
|
|
264
|
+
return { at: new Date(first.getFullYear(), first.getMonth(), first.getDate() + 1, first.getHours(), first.getMinutes()), day: 'tomorrow' }
|
|
265
|
+
}
|
|
266
|
+
|
|
228
267
|
export async function removeAutosync() {
|
|
229
268
|
await SCHEDULERS[platform()]?.remove()
|
|
230
269
|
return { removed: true }
|
package/src/nudge.mjs
CHANGED
|
@@ -11,6 +11,56 @@ export function shouldNudge(streak, config) {
|
|
|
11
11
|
return streak.atRisk === true && streak.current > 0 && config.lastNudge !== streak.today
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// What `nudge` was asked to do. Alone it shows the nudge's status; with
|
|
15
|
+
// --quiet and nothing else it is the scheduled check as 0.1.0 installed it
|
|
16
|
+
// (`nudge --quiet`), so those jobs keep working until autosync is rerun.
|
|
17
|
+
export function nudgeAction(rest, values) {
|
|
18
|
+
if (['on', 'off', 'test', 'run', 'at'].includes(rest[0])) return rest[0]
|
|
19
|
+
return values.quiet ? 'run' : 'status'
|
|
20
|
+
}
|
|
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
|
+
|
|
14
64
|
export function nudgeText(streak) {
|
|
15
65
|
const days = streak.current === 1 ? '1-day streak' : `${streak.current}-day streak`
|
|
16
66
|
const next = streak.next && streak.current + 1 >= streak.next.days ? ` Today makes it ${streak.next.name}.` : ''
|