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.
@@ -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
- if (rest[0] === 'on' || rest[0] === 'off') {
122
- config.nudge = rest[0] === 'on'
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 ? 'on: 20:30 each evening when your streak is at risk' : 'off'}.`)
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 (rest[0] === 'test') {
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
- // The evening run: sync, then nudge if the streak would end at midnight.
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.0",
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 const NUDGE_TIME = [20, 30]
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
- 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) => [
26
29
  { name: 'sync', args: ['sync', '--quiet'], times: SYNC_TIMES },
27
- ...(nudge ? [{ name: 'nudge', args: ['nudge', '--quiet'], times: [NUDGE_TIME] }] : []),
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
- 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
+ }
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 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(() => {})
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(NUDGE_TIME) : undefined
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}.` : ''