One Bible verse a day above your prompt while Claude works, a little more hidden each wait until you know it by heart.

Turn AI waiting time into Scripture time.
While Claude is working, a small band above the prompt shows today's verse, from the YouVersion Platform. It starts in the Berean Standard Bible; click the version (BSB) to pick the NIV, NASB or AMP instead. Each wait hides a little more of it:
| Looks today | Step | What you see |
|---|---|---|
| 1-6 | Read | The full verse |
| 7-15 | Repeat | A few words blanked, building to about a third |
| 16-24 | Recall | Those words and more cut to first letters, building to most |
| 25+ | Check | Every word reduced to its first letter; Reveal, then "Got it" |
The band disappears the instant Claude needs you. Focus it (click, or ctrl+x tab) and press r to reveal or g for Got it.
Everyone sees the same verse on the same day (a 90-verse cycle), the same as the Mac app.
Needs Claude Code 2.1.287 or later (claude update). In your terminal:
claude plugin marketplace add ToastMobile/wait-with-god-plugins && claude plugin install wait-with-god@wait-with-god
Your next Claude Code session has it.
| Command | What it does |
|---|---|
/wait | Today's verse and your totals: time redeemed today and all time, reviews, verses memorized, workday streak |
/wait copy | Copies the whole verse, never the blanked one |
/wait next | Switches today's verse to the next one you haven't memorized, from Read |
/wait version | Picks a Bible version (the same as clicking it on the band); /wait version niv names one |
/wait hide | Hides the band for an hour; /wait hide today for the rest of the day |
/wait show | Shows it again |
/wait reset | Restarts today's verse at Read |
/wait runs right away, even while Claude is working. Nothing counts while the band is hidden.
The plugin fetches the verse text from YouVersion (api.youversion.com), once per verse and version. Once a day it sends waitwithgod.com an anonymous usage count: the plugin version, the day you installed it, your streak and verses memorized, and your looks and minutes on your last day with it. Nothing in it identifies you. Your stats stay in the plugin's own store under your Claude config directory.
Brought to you by Versle. Toast Apps and Software LLC.
hooks/register.tsx 487 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { WaitStats, WaitToday } from '../types'
5import type { PlanVerse, Version } from './verses'
6import {
7 APP_KEY,
8 BSB,
9 clockTime,
10 dayKey,
11 emptyStats,
12 hideUntil,
13 mask,
14 minutes,
15 nextVerse,
16 passageUrl,
17 planFor,
18 quoted,
19 shared,
20 SITE,
21 stageFor,
22 verseFor,
23 versionNamed,
24 versionOf,
25 VERSIONS,
26 verseText,
27 workdayStreak,
28} from './verses'
29
30const today = atom({ plugin: 'wait-with-god', key: 'today' } as const, null)
31const isRevealed = atom({ plugin: 'wait-with-god', key: 'isRevealed' } as const, false)
32const hiddenUntil = atom({ plugin: 'wait-with-god', key: 'hiddenUntil' } as const, null)
33const shareResult = atom({ plugin: 'wait-with-god', key: 'shareResult' } as const, null)
34
35const SHARE_LABEL = { copied: 'Copied', failed: "Couldn't copy" } as const
36
37const VERSLE = 'https://get.versle.app/p/waitwithgod'
38
39/** The version picker's pane. */
40const VERSION_PANE = 'version'
41
42/** Must match .claude-plugin/plugin.json (release.sh checks). */
43const VERSION = '0.3.8'
44const EVENTS = 'https://waitwithgod.com/a'
45
46const HINT = {
47 Read: 'Read it slowly.',
48 Repeat: 'Fill in the blanks.',
49 Recall: 'Recall it from the first letters.',
50 Check: 'Say it from memory, then reveal.',
51} as const
52
53const USAGE = [
54 '/wait today\'s verse and your totals',
55 '/wait copy copy the whole verse',
56 '/wait next switch to the next verse you haven\'t memorized',
57 '/wait version [v] pick a Bible version, or name one: /wait version niv',
58 '/wait hide [today] hide the band for an hour, or for the rest of today',
59 '/wait show show it again',
60 '/wait reset restart today\'s verse at Read',
61].join('\n')
62
63async function loadStats($: EngineInterface): Promise<WaitStats> {
64 const saved = (await $.store.get('stats')) as Partial<WaitStats> | undefined
65 return { ...emptyStats(), ...(saved ?? {}) }
66}
67
68async function saveToday($: EngineInterface, verse: WaitToday) {
69 await $.store.set('today', verse)
70 await update($, today, () => verse)
71}
72
73/** A plan verse's text in a version, fetched once and cached. */
74async function textFor($: EngineInterface, plan: PlanVerse, bible: number): Promise<string | null> {
75 const key = `text:${bible}:${plan.ref}`
76 const cached = (await $.store.get(key)) as string | undefined
77 if (cached) return cached
78 const res = await $.http.fetch(passageUrl(plan, bible), { headers: { 'x-yvp-app-key': APP_KEY, accept: 'application/json' } })
79 if (!res.ok) return null
80 const text = verseText(res.text)
81 if (text) await $.store.set(key, text)
82 return text
83}
84
85/** The version new verses are read in: the reader's pick, the BSB until they make one. */
86async function chosenVersion($: EngineInterface): Promise<Version> {
87 return versionOf((await $.store.get('bible')) as number | undefined)
88}
89
90/** Makes `plan` today's verse, starting from Read. */
91async function startVerse($: EngineInterface, day: string, plan: PlanVerse): Promise<WaitToday | null> {
92 const bible = (await chosenVersion($)).id
93 const text = await textFor($, plan, bible)
94 if (!text) return null
95 const stats = await loadStats($)
96 const fresh: WaitToday = { day, ref: plan.ref, text, bible, views: 0, isMastered: stats.mastered.includes(plan.ref) }
97 await saveToday($, fresh)
98 await update($, isRevealed, () => false)
99 return fresh
100}
101
102/** Switches to another version: today's verse now, at the step it's on, and every verse after it. */
103async function useVersion($: EngineInterface, bible: number): Promise<WaitToday | null> {
104 const verse = await ensureToday($)
105 const plan = verse && planFor(verse.ref)
106 if (!verse || !plan) return null
107 const text = await textFor($, plan, bible)
108 if (!text) return null
109 await $.store.set('bible', bible)
110 const switched: WaitToday = { ...verse, text, bible }
111 await saveToday($, switched)
112 return switched
113}
114
115/** Opens the version picker as a dialog; false where no pane can be drawn. */
116async function openVersions($: EngineInterface): Promise<boolean> {
117 const opened = await $.ui.open({
118 id: VERSION_PANE,
119 title: 'Bible version',
120 focus: true,
121 closeOnEscape: true,
122 holdToasts: true,
123 rows: VERSIONS.length + 4,
124 })
125 return opened.isPlaced
126}
127
128/** The versions as a list to read, the one in use checked. */
129function versionList(current: Version): string {
130 return VERSIONS.map(v => `${v.id === current.id ? '✓' : ' '} ${v.abbr.padEnd(9)} ${v.title}`).join('\n')
131}
132
133/** Makes sure `today` holds today's verse, fetching its text once per verse and version. */
134async function ensureToday($: EngineInterface): Promise<WaitToday | null> {
135 const day = dayKey(await $.clock.now())
136 const held = await read($, today)
137 if (held?.day === day) return held
138
139 const saved = (await $.store.get('today')) as WaitToday | undefined
140 if (saved?.day === day) {
141 await update($, today, () => saved)
142 return saved
143 }
144 return startVerse($, day, verseFor(day))
145}
146
147/** Hidden by `/wait hide`, until it runs out. */
148async function isHidden($: EngineInterface): Promise<boolean> {
149 const until = await read($, hiddenUntil)
150 return until !== null && (await $.clock.now()) < until
151}
152
153async function setHidden($: EngineInterface, until: number | null) {
154 await $.store.set('hiddenUntil', until)
155 await update($, hiddenUntil, () => until)
156}
157
158/**
159 * The anonymous usage count the README describes: "install" once, then "active" for each day with a wait,
160 * carrying the previous active day's totals. Nothing identifies the person; the install date is what retention
161 * is counted by.
162 */
163type Usage = { surface: string; isSending: boolean }
164
165/** How far back an active day an earlier session didn't get to send is still sent. */
166const CATCH_UP_MS = 7 * 86_400_000
167
168async function post($: EngineInterface, event: Record<string, unknown>): Promise<boolean> {
169 const res = await $.http.fetch(EVENTS, {
170 method: 'POST',
171 headers: { 'content-type': 'application/json', 'user-agent': `wait-with-god-plugin/${VERSION}` },
172 body: JSON.stringify(event),
173 })
174 return res.ok
175}
176
177async function sendUsage($: EngineInterface, usage: Usage) {
178 if (usage.isSending) return
179 usage.isSending = true
180 try {
181 const dnt = (await $.env.get('DO_NOT_TRACK'))?.toLowerCase()
182 if (dnt === '1' || dnt === 'true') return
183 const installed = (await $.store.get('installedOn')) as string | undefined
184 if (!installed) return
185 const base = { client: 'plugin', v: VERSION, platform: usage.surface, app: 'claude-code', installed }
186
187 if ((await $.store.get('installReported')) !== true && (await post($, { e: 'install', ...base, day: installed }))) {
188 await $.store.set('installReported', true)
189 }
190
191 // Every active day not yet sent, oldest first, so a session that ended quickly is caught up by the next.
192 const stats = await loadStats($)
193 const reported = ((await $.store.get('reportedDay')) as string | undefined) ?? ''
194 const oldest = dayKey((await $.clock.now()) - CATCH_UP_MS)
195 for (const day of stats.activeDays) {
196 if (day <= reported || day < oldest || day < installed) continue
197 const prev = stats.activeDays.filter(d => d < day).at(-1)
198 const event = {
199 e: 'active',
200 ...base,
201 day,
202 streak: workdayStreak(stats.activeDays, day),
203 mastered: stats.mastered.length,
204 ...(prev && {
205 prev_day: prev,
206 prev_looks: stats.looksByDay[prev] ?? 0,
207 prev_waited_s: Math.round((stats.waitedByDay[prev] ?? 0) / 1000),
208 }),
209 }
210 if (!(await post($, event))) return
211 await $.store.set('reportedDay', day)
212 }
213 } catch {
214 // Offline: the next check tries again.
215 } finally {
216 usage.isSending = false
217 }
218}
219
220export const register: Register = on => {
221 const startedAt = new Map<string, number>()
222 const usage: Usage = { surface: 'none', isSending: false }
223 // Usage is only sent from a session a person is at, not a -p run.
224 let isInteractive = false
225
226 on('session.start', async ($, e, next) => {
227 await $.command.register({
228 name: 'wait',
229 description: "Today's verse and how much waiting you've redeemed",
230 argumentHint: '[copy | next | version [name] | hide [today] | show | reset]',
231 immediate: true,
232 })
233 const until = (await $.store.get('hiddenUntil')) as number | null | undefined
234 await update($, hiddenUntil, () => until ?? null)
235 try {
236 await ensureToday($)
237 } catch {
238 // Offline: try again on the next turn.
239 }
240 // Usage goes out on timers, never in a turn's way, and only from a session a person is at.
241 usage.surface = e.surface ?? 'none'
242 if (e.isInteractive) {
243 if (!(await $.store.get('installedOn'))) {
244 const stats = await loadStats($)
245 await $.store.set('installedOn', stats.activeDays[0] ?? dayKey(await $.clock.now()))
246 // Someone who used the plugin before usage counting is not a new install, and their past days aren't sent.
247 if (stats.activeDays.length > 0) {
248 await $.store.set('installReported', true)
249 await $.store.set('reportedDay', stats.activeDays.at(-1))
250 }
251 }
252 isInteractive = true
253 $.clock.after(5_000, () => void sendUsage($, usage))
254 $.clock.every(60_000, () => void sendUsage($, usage))
255 }
256 return next(e)
257 })
258
259 on('turn.start', async ($, e, next) => {
260 // Hidden: no look, no time counted, as if the band weren't installed.
261 if (await isHidden($)) return next(e)
262 startedAt.set(e.turnId, await $.clock.now())
263 try {
264 const verse = await ensureToday($)
265 if (verse) {
266 await saveToday($, { ...verse, views: verse.views + 1 })
267 await update($, isRevealed, () => false)
268 }
269 } catch {
270 // Never hold up a turn over Scripture loading.
271 }
272 return next(e)
273 })
274
275 on('turn.complete', async ($, e, next) => {
276 const began = startedAt.get(e.turnId)
277 startedAt.delete(e.turnId)
278 const verse = await read($, today)
279 if (began !== undefined && verse) {
280 const waited = (await $.clock.now()) - began
281 const stats = await loadStats($)
282 stats.waitedMs += waited
283 stats.waitedByDay[verse.day] = (stats.waitedByDay[verse.day] ?? 0) + waited
284 stats.reviews[verse.ref] = (stats.reviews[verse.ref] ?? 0) + 1
285 stats.looksByDay[verse.day] = (stats.looksByDay[verse.day] ?? 0) + 1
286 if (!stats.activeDays.includes(verse.day)) stats.activeDays = [...stats.activeDays, verse.day].slice(-400)
287 await $.store.set('stats', stats)
288 // A new active day goes out a moment later, even if the session ends soon after.
289 if (isInteractive) $.clock.after(1_000, () => void sendUsage($, usage))
290 }
291 return next(e)
292 })
293
294 on('command.run', { command: 'wait' }, async ($, e) => {
295 const [sub = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
296 const offline = { text: "Couldn't reach the Bible API. Wait with God will try again on your next prompt." }
297
298 switch (sub) {
299 case '':
300 break
301 case 'reset': {
302 const verse = await ensureToday($)
303 if (verse) await saveToday($, { ...verse, views: 0 })
304 return { text: "Today's verse is back to the Read step." }
305 }
306 case 'copy': {
307 const verse = await ensureToday($)
308 if (!verse) return offline
309 const text = quoted(verse.ref, verse.text, versionOf(verse.bible).abbr)
310 const copied = await $.ui.copy({ text })
311 return { text: copied.isCopied ? `Copied ${verse.ref}.` : `Couldn't reach the clipboard. Here it is to copy:\n\n${text}` }
312 }
313 case 'next': {
314 const verse = await ensureToday($)
315 if (!verse) return offline
316 const stats = await loadStats($)
317 const swapped = await startVerse($, verse.day, nextVerse(verse.ref, stats.mastered))
318 if (!swapped) return offline
319 return { text: `Today's verse is now ${swapped.ref}, starting from Read. Tomorrow goes back to the plan.` }
320 }
321 case 'version': {
322 const current = versionOf((await read($, today))?.bible ?? (await chosenVersion($)).id)
323 if (arg === '') {
324 if (await openVersions($)) return { text: 'Pick a Bible version in the picker, or name one: /wait version niv' }
325 return { text: `${versionList(current)}\n\nName one to switch: /wait version niv` }
326 }
327 const version = versionNamed(arg)
328 if (!version) return { text: `There's no version called ${arg} here. These are:\n\n${versionList(current)}` }
329 if (!(await useVersion($, version.id))) return offline
330 return { text: `Reading in the ${version.title} (${version.abbr}) from now on.` }
331 }
332 case 'hide': {
333 if (arg !== '' && arg !== 'today' && arg !== 'hour' && arg !== '1h') return { text: USAGE }
334 const span = arg === 'today' ? 'today' : 'hour'
335 const until = hideUntil(span, await $.clock.now())
336 await setHidden($, until)
337 return {
338 text: `${span === 'today' ? 'Hidden for the rest of today' : `Hidden until ${clockTime(until)}`}. /wait show brings it back.`,
339 }
340 }
341 case 'show':
342 await setHidden($, null)
343 return { text: 'Wait with God will show on your next prompt.' }
344 default:
345 return { text: USAGE }
346 }
347
348 const verse = await ensureToday($)
349 const stats = await loadStats($)
350 if (!verse) return offline
351
352 const todayMs = stats.waitedByDay[verse.day] ?? 0
353 const reviews = stats.reviews[verse.ref] ?? 0
354 const streak = workdayStreak(stats.activeDays, verse.day)
355 const until = await read($, hiddenUntil)
356 const version = versionOf(verse.bible)
357 const lines = [
358 `${verse.ref} (${version.abbr})`,
359 `"${verse.text}"`,
360 '',
361 `Step: ${stageFor(verse.views)} · ${verse.views} look${verse.views === 1 ? '' : 's'} today${verse.isMastered ? ' · mastered' : ''}`,
362 `Today you redeemed ${minutes(todayMs)} of waiting and reviewed ${verse.ref} ${reviews} time${reviews === 1 ? '' : 's'}.`,
363 `All time: ${minutes(stats.waitedMs)} redeemed · ${stats.mastered.length} verse${stats.mastered.length === 1 ? '' : 's'} memorized · ${streak}-workday streak.`,
364 ...((await isHidden($)) && until !== null ? [`Hidden until ${clockTime(until)} · /wait show`] : []),
365 '',
366 version.notice,
367 ]
368 return { text: lines.join('\n') }
369 })
370
371 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
372 // Only while Claude is busy: the instant it needs you, the band is gone.
373 if (!e.props.isWorking || e.props.hasSurvey) return next(e)
374 if (await isHidden($)) return next(e)
375 const verse = await read($, today)
376 if (!verse || verse.views === 0) return next(e)
377
378 const { Box, Text, Button, Link } = $.ui.resolve(e)
379 const stage = stageFor(verse.views)
380 const revealed = stage === 'Read' || (await read($, isRevealed))
381 const shown = revealed ? verse.text : mask(verse.text, verse.views)
382 const shareState = await read($, shareResult)
383 const version = versionOf(verse.bible)
384
385 return (
386 <Box flexDirection="column" width={e.props.bodyColumns}>
387 <Box flexDirection="row">
388 <Text dimColor>
389 <Link href={SITE}>Wait with God</Link> · {verse.ref} (
390 </Text>
391 <Button
392 key="version"
393 label={version.abbr}
394 plain
395 dimColor
396 onPress={async () => {
397 if (!(await openVersions($))) $.ui.toast('Pick a Bible version with /wait version.')
398 }}
399 />
400 <Text dimColor>) · {revealed && stage !== 'Read' ? 'Revealed.' : HINT[stage]}</Text>
401 </Box>
402 <Text wrap="wrap">
403 {shown}
404 </Text>
405 <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
406 <Box flexDirection="row" gap={1}>
407 {stage !== 'Read' && !revealed && (
408 <Button key="reveal" label="Reveal" hotkey="r" onPress={() => update($, isRevealed, () => true)} />
409 )}
410 {stage === 'Check' && !verse.isMastered && (
411 <Button
412 key="got"
413 label="Got it"
414 hotkey="g"
415 variant="primary"
416 onPress={async () => {
417 const stats = await loadStats($)
418 if (!stats.mastered.includes(verse.ref)) stats.mastered = [...stats.mastered, verse.ref]
419 await $.store.set('stats', stats)
420 await saveToday($, { ...verse, isMastered: true })
421 await update($, isRevealed, () => true)
422 $.ui.toast(
423 `${verse.ref} memorized. ${stats.mastered.length} verse${stats.mastered.length === 1 ? '' : 's'} learned while waiting.`,
424 )
425 }}
426 />
427 )}
428 {stage === 'Check' && verse.isMastered && <Text dimColor>✓ mastered</Text>}
429 <Button
430 key="share"
431 label={shareState ? SHARE_LABEL[shareState] : 'Share'}
432 hotkey="s"
433 dimColor={!shareState}
434 onPress={async press => {
435 const copied = await $.ui.copy({ text: shared(verse.ref, verse.text, version.abbr), surface: press.surface })
436 // The button says what happened for a moment, then goes back to Share.
437 await update($, shareResult, () => (copied.isCopied ? 'copied' : 'failed'))
438 $.clock.after(2_000, () => void update($, shareResult, () => null))
439 }}
440 />
441 </Box>
442 <Text>
443 <Text dimColor>From </Text>
444 <Link href={VERSLE}>Versle</Link>
445 </Text>
446 </Box>
447 </Box>
448 )
449 })
450
451 // The version picker: every version, the one in use checked and focused, and its notice under them.
452 on('ui.render', { component: 'Pane', requestId: VERSION_PANE }, async ($, e) => {
453 const { Box, Text, Button } = $.ui.resolve(e)
454 const verse = await read($, today)
455 const current = verse ? versionOf(verse.bible) : await chosenVersion($)
456 const width = Math.max(...VERSIONS.map(v => v.abbr.length)) + 2
457
458 return (
459 <Box flexDirection="column" width={e.props.bodyColumns}>
460 {VERSIONS.map(v => (
461 <Button
462 key={`version-${v.id}`}
463 label={`${v.id === current.id ? '✓' : ' '} ${v.abbr.padEnd(width)}${v.title}`}
464 plain
465 dimColor={v.id !== current.id}
466 {...(v.id === current.id ? { autoFocus: true as const } : {})}
467 onPress={async () => {
468 const switched = await useVersion($, v.id).catch(() => null)
469 await $.ui.close({ id: VERSION_PANE })
470 $.ui.toast(
471 switched
472 ? `Reading in the ${v.title} (${v.abbr}) from now on.`
473 : "Couldn't reach the Bible API. The version didn't change.",
474 )
475 }}
476 />
477 ))}
478 <Box marginTop={1}>
479 <Text dimColor wrap="wrap">
480 {current.notice}
481 </Text>
482 </Box>
483 </Box>
484 )
485 })
486}
487hooks/verses.ts 320 lines1import type { WaitStage, WaitStats } from '../types'
2
3/** One verse a day, cycling through 90. Short, well-known memory verses that every version numbers alike. */
4export const PLAN: ReadonlyArray<readonly [book: string, name: string, chapter: number, verse: number]> = [
5 ['ROM', 'Romans', 8, 28],
6 ['JHN', 'John', 3, 16],
7 ['PSA', 'Psalm', 118, 24],
8 ['JOS', 'Joshua', 1, 9],
9 ['PHP', 'Philippians', 4, 6],
10 ['ISA', 'Isaiah', 40, 31],
11 ['PSA', 'Psalm', 119, 105],
12 ['MAT', 'Matthew', 11, 28],
13 ['ROM', 'Romans', 12, 2],
14 ['2TI', '2 Timothy', 1, 7],
15 ['PSA', 'Psalm', 46, 10],
16 ['GAL', 'Galatians', 2, 20],
17 ['PSA', 'Psalm', 37, 4],
18 ['HEB', 'Hebrews', 11, 1],
19 ['1PE', '1 Peter', 5, 7],
20 ['JAS', 'James', 1, 5],
21 ['MAT', 'Matthew', 6, 33],
22 ['ROM', 'Romans', 5, 8],
23 ['2CO', '2 Corinthians', 5, 17],
24 ['ISA', 'Isaiah', 26, 3],
25 ['HEB', 'Hebrews', 13, 8],
26 ['1JN', '1 John', 1, 9],
27 ['MIC', 'Micah', 6, 8],
28 ['LAM', 'Lamentations', 3, 22],
29 ['JHN', 'John', 14, 6],
30 ['PRO', 'Proverbs', 18, 10],
31 ['ISA', 'Isaiah', 41, 10],
32 ['PHP', 'Philippians', 4, 13],
33 ['PSA', 'Psalm', 23, 1],
34 ['JER', 'Jeremiah', 29, 11],
35 ['MAT', 'Matthew', 5, 16],
36 ['PRO', 'Proverbs', 3, 5],
37 ['ROM', 'Romans', 6, 23],
38 ['PSA', 'Psalm', 27, 1],
39 ['EPH', 'Ephesians', 2, 8],
40 ['DEU', 'Deuteronomy', 31, 6],
41 ['JHN', 'John', 1, 1],
42 ['PSA', 'Psalm', 139, 14],
43 ['2CO', '2 Corinthians', 12, 9],
44 ['ISA', 'Isaiah', 53, 5],
45 ['MAT', 'Matthew', 7, 7],
46 ['PSA', 'Psalm', 51, 10],
47 ['PHP', 'Philippians', 1, 6],
48 ['GEN', 'Genesis', 1, 1],
49 ['JHN', 'John', 14, 27],
50 ['PSA', 'Psalm', 34, 8],
51 ['HEB', 'Hebrews', 4, 12],
52 ['PRO', 'Proverbs', 16, 3],
53 ['1CO', '1 Corinthians', 13, 4],
54 ['ISA', 'Isaiah', 43, 2],
55 ['JHN', 'John', 16, 33],
56 ['PSA', 'Psalm', 91, 1],
57 ['ROM', 'Romans', 10, 9],
58 ['ZEP', 'Zephaniah', 3, 17],
59 ['MAT', 'Matthew', 6, 34],
60 ['PSA', 'Psalm', 56, 3],
61 ['2TI', '2 Timothy', 3, 16],
62 ['NUM', 'Numbers', 6, 24],
63 ['JHN', 'John', 15, 5],
64 ['PSA', 'Psalm', 103, 12],
65 ['GAL', 'Galatians', 6, 9],
66 ['JER', 'Jeremiah', 33, 3],
67 ['1JN', '1 John', 4, 19],
68 ['PSA', 'Psalm', 121, 2],
69 ['EPH', 'Ephesians', 4, 32],
70 ['ISA', 'Isaiah', 55, 8],
71 ['MAT', 'Matthew', 22, 37],
72 ['PSA', 'Psalm', 19, 14],
73 ['COL', 'Colossians', 3, 23],
74 ['NAM', 'Nahum', 1, 7],
75 ['JHN', 'John', 8, 32],
76 ['PSA', 'Psalm', 55, 22],
77 ['1CO', '1 Corinthians', 10, 13],
78 ['ECC', 'Ecclesiastes', 3, 1],
79 ['ROM', 'Romans', 15, 13],
80 ['PSA', 'Psalm', 16, 11],
81 ['JAS', 'James', 4, 8],
82 ['ISA', 'Isaiah', 9, 6],
83 ['LUK', 'Luke', 1, 37],
84 ['PSA', 'Psalm', 30, 5],
85 ['1TH', '1 Thessalonians', 5, 18],
86 ['PRO', 'Proverbs', 16, 9],
87 ['JHN', 'John', 10, 10],
88 ['PSA', 'Psalm', 147, 3],
89 ['EPH', 'Ephesians', 6, 10],
90 ['ISA', 'Isaiah', 40, 29],
91 ['MRK', 'Mark', 10, 45],
92 ['PSA', 'Psalm', 90, 12],
93 ['REV', 'Revelation', 21, 4],
94 ['PHP', 'Philippians', 4, 19],
95]
96
97/** YouVersion Platform Bibles; a version's passages are under its id. */
98export const API = 'https://api.youversion.com/v1/bibles'
99
100/** The YouVersion Platform app key, sent as X-YVP-App-Key. App keys aren't secrets; they're meant to ship in apps. */
101export const APP_KEY = 'NTVCOKh2HGM5lsah3PipHeda4HLKIAZr5ecXygaiGgkawuGD'
102
103/** A Bible version to read in: YouVersion's id, the abbreviation shown, its name, and the notice it asks for. */
104export type Version = { id: number; abbr: string; title: string; notice: string }
105
106/**
107 * The versions to pick from: a few of the most read that the app key reaches, the BSB first. The Mac app has the
108 * same list.
109 */
110export const VERSIONS: readonly Version[] = [
111 { id: 3034, abbr: 'BSB', title: 'Berean Standard Bible', notice: 'The Berean Standard Bible is in the public domain.' },
112 { id: 111, abbr: 'NIV', title: 'New International Version', notice: 'The Holy Bible, New International Version® NIV® Copyright © 1973, 1978, 1984, 2011 by Biblica, Inc.® Used by Permission of Biblica, Inc.® All rights reserved worldwide.' },
113 { id: 2692, abbr: 'NASB', title: 'New American Standard Bible', notice: 'New American Standard Bible® NASB® Copyright © 1960, 1971, 1977, 1995, 2020 by The Lockman Foundation, a Corporation Not for Profit, La Habra, CA. All Rights Reserved. www.lockman.org' },
114 { id: 1588, abbr: 'AMP', title: 'Amplified Bible', notice: 'Amplified® Bible Copyright © 2015 by The Lockman Foundation, La Habra, CA 90631. All rights reserved. www.lockman.org' },
115]
116
117/** The version a new reader starts with, and the one older saved verses are in. */
118export const BSB = 3034
119
120/** A version by id; the BSB for one that isn't in the list (or none). */
121export function versionOf(id: number | undefined): Version {
122 return VERSIONS.find(v => v.id === id) ?? VERSIONS.find(v => v.id === BSB)!
123}
124
125/** A version by its abbreviation, any case. */
126export function versionNamed(abbr: string): Version | undefined {
127 return VERSIONS.find(v => v.abbr.toLowerCase() === abbr.toLowerCase())
128}
129
130const DAY_MS = 86_400_000
131
132/** Local calendar day for a timestamp, YYYY-MM-DD. */
133export function dayKey(ms: number): string {
134 const d = new Date(ms)
135 const p = (n: number) => String(n).padStart(2, '0')
136 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
137}
138
139export type PlanVerse = { book: string; chapter: number; verse: number; ref: string }
140
141/** The plan entry at an index, wrapping around the plan. */
142function planVerse(index: number): PlanVerse {
143 const [book, name, chapter, verse] = PLAN[((index % PLAN.length) + PLAN.length) % PLAN.length]!
144 return { book, chapter, verse, ref: `${name} ${chapter}:${verse}` }
145}
146
147/** The plan entry with a reference. */
148export function planFor(ref: string): PlanVerse | undefined {
149 const index = PLAN.findIndex((_, i) => planVerse(i).ref === ref)
150 return index < 0 ? undefined : planVerse(index)
151}
152
153/** Where a plan verse's passage is in a version. */
154export function passageUrl(plan: PlanVerse, bible: number): string {
155 return `${API}/${bible}/passages/${plan.book}.${plan.chapter}.${plan.verse}?format=html`
156}
157
158/** The plan entry for a day: everyone on the same day sees the same verse. */
159export function verseFor(day: string): PlanVerse {
160 return planVerse(Math.floor(Date.parse(`${day}T00:00:00Z`) / DAY_MS))
161}
162
163/** The verse after `ref` in the plan that isn't memorized yet, or simply the next one once all are. */
164export function nextVerse(ref: string, mastered: readonly string[]): PlanVerse {
165 const start = Math.max(PLAN.findIndex((_, i) => planVerse(i).ref === ref), 0)
166 const upcoming = Array.from({ length: PLAN.length - 1 }, (_, i) => planVerse(start + 1 + i))
167 return upcoming.find(v => !mastered.includes(v.ref)) ?? upcoming[0]!
168}
169
170export const SITE = 'https://waitwithgod.com'
171
172/** The verse as it is copied: whole, never blanked, with its version. */
173export function quoted(ref: string, text: string, abbr: string): string {
174 return `“${text}” — ${ref} (${abbr})`
175}
176
177/** The verse as it is shared: whole, with the website, as one message. */
178export function shared(ref: string, text: string, abbr: string): string {
179 return `${quoted(ref, text, abbr)}\n\n${SITE}`
180}
181
182/** When a hide runs out: an hour from now, or the start of tomorrow (local time). */
183export function hideUntil(span: 'hour' | 'today', now: number): number {
184 if (span === 'hour') return now + 3_600_000
185 const d = new Date(now)
186 return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime()
187}
188
189/** Local clock time, e.g. "3:05 PM". */
190export function clockTime(ms: number): string {
191 const d = new Date(ms)
192 const h = d.getHours() % 12 || 12
193 return `${h}:${String(d.getMinutes()).padStart(2, '0')} ${d.getHours() < 12 ? 'AM' : 'PM'}`
194}
195
196const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
197
198/**
199 * Pulls a verse's plain text out of a YouVersion passage body (format=html). It drops the verse numbers and a
200 * psalm's title, and puts the divine name in capitals (LORD) as Bibles print it, in small capitals; the text format
201 * loses both. The LSV marks its poetry lines with "||", which become spaces like every other version's line breaks.
202 */
203export function verseText(body: string): string | null {
204 const html = (JSON.parse(body) as { content?: string }).content
205 if (!html) return null
206 const text = html
207 .replace(/<div class="d">.*?<\/div>/gs, ' ')
208 .replace(/<span class="(?:yv-vlbl|va)">.*?<\/span>/g, '')
209 .replace(/<span class="(?:nd|sc)">(.*?)<\/span>/g, (_, name: string) => name.toUpperCase())
210 .replace(/<\/?div[^>]*>/g, ' ')
211 .replace(/<[^>]*>/g, '')
212 .replace(/&#(x?)([\da-f]+);/gi, (_, hex: string, code: string) => String.fromCodePoint(parseInt(code, hex ? 16 : 10)))
213 .replace(/&(\w+);/g, (entity, name: string) => ENTITIES[name] ?? entity)
214 .replace(/\s\|\|\s/g, ' ')
215 .replace(/\s+/g, ' ')
216 .trim()
217 return text || null
218}
219
220/** The looks that show Repeat and Recall, and the share of words each blanks from its first look to its last. */
221const REPEAT = { first: 7, last: 15, start: 0.1, rise: 0.25 }
222const RECALL = { first: 16, last: 24, start: 0.4, rise: 0.4 }
223
224/** Which memorization step a number of views lands on. */
225export function stageFor(views: number): WaitStage {
226 if (views < REPEAT.first) return 'Read'
227 if (views <= REPEAT.last) return 'Repeat'
228 if (views <= RECALL.last) return 'Recall'
229 return 'Check'
230}
231
232/** Small deterministic PRNG so the same view hides the same words on every redraw. */
233function rng(seed: number) {
234 let s = seed >>> 0 || 1
235 return () => {
236 s ^= s << 13
237 s ^= s >>> 17
238 s ^= s << 5
239 return ((s >>> 0) % 10_000) / 10_000
240 }
241}
242
243function hash(text: string): number {
244 let h = 2166136261
245 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619)
246 return h >>> 0
247}
248
249const WORD = /^([^\p{L}\p{N}]*)([\p{L}\p{N}'’-]+)([^\p{L}\p{N}]*)$/u
250
251function blank(word: string, keepFirst: boolean): string {
252 const m = WORD.exec(word)
253 if (!m) return word
254 const [, lead, core, tail] = m as unknown as [string, string, string, string]
255 const letters = [...core]
256 const shown = keepFirst ? letters[0]! + '_'.repeat(letters.length - 1) : '_'.repeat(letters.length)
257 return lead + shown + tail
258}
259
260/** How many words to blank, at least one: Repeat goes from 10% to 35% of them, Recall from 40% to 80%. */
261function blankCount(views: number, total: number): number {
262 const { first, last, start, rise } = stageFor(views) === 'Repeat' ? REPEAT : RECALL
263 const progress = Math.min(Math.max((views - first) / (last - first), 0), 1)
264 return Math.max(1, Math.round((start + rise * progress) * total))
265}
266
267/**
268 * The verse after so many views today:
269 * Read shows it whole. Repeat blanks a few words at first and works up to about a third. Recall cuts
270 * those and more to their first letters, working up to most of them. Check cuts them all. The words go
271 * in a fixed order per verse, so each look keeps the earlier blanks.
272 */
273export function mask(text: string, views: number): string {
274 const stage = stageFor(views)
275 if (stage === 'Read') return text
276 const words = text.split(' ')
277 if (stage === 'Check') return words.map(w => blank(w, true)).join(' ')
278 const order = words.flatMap((w, i) => (WORD.test(w) ? [i] : []))
279 const next = rng(hash(text))
280 for (let i = order.length - 1; i > 0; i--) {
281 const j = Math.floor(next() * (i + 1))
282 ;[order[i], order[j]] = [order[j]!, order[i]!]
283 }
284 const hidden = new Set(order.slice(0, blankCount(views, order.length)))
285 return words.map((w, i) => (hidden.has(i) ? blank(w, stage === 'Recall') : w)).join(' ')
286}
287
288export function emptyStats(): WaitStats {
289 return { waitedMs: 0, waitedByDay: {}, reviews: {}, looksByDay: {}, mastered: [], activeDays: [] }
290}
291
292function isWeekday(day: string): boolean {
293 const dow = new Date(`${day}T12:00:00Z`).getUTCDay()
294 return dow !== 0 && dow !== 6
295}
296
297/** Consecutive workdays (Mon–Fri) with a wait, ending today; weekends neither count nor break it. */
298export function workdayStreak(activeDays: readonly string[], today: string): number {
299 const active = new Set(activeDays)
300 let streak = 0
301 let cursor = Date.parse(`${today}T12:00:00Z`)
302 for (let i = 0; i < 400; i++) {
303 const day = new Date(cursor).toISOString().slice(0, 10)
304 if (isWeekday(day)) {
305 if (active.has(day)) streak += 1
306 else if (day !== today) break
307 }
308 cursor -= DAY_MS
309 }
310 return streak
311}
312
313export function minutes(ms: number): string {
314 const total = Math.round(ms / 1000)
315 if (total < 60) return `${total}s`
316 const m = Math.floor(total / 60)
317 if (m < 60) return `${m}m ${total % 60}s`
318 return `${Math.floor(m / 60)}h ${m % 60}m`
319}
320types/index.d.ts 47 lines1/** Today's verse and how many times it has been seen while Claude worked. */
2export type WaitToday = {
3 /** Local date, YYYY-MM-DD. */
4 day: string
5 /** Human reference, e.g. "Romans 8:28". */
6 ref: string
7 /** Text of the verse, in `bible`. */
8 text: string
9 /** YouVersion id of the version `text` is in; older saved verses lack it and are the BSB. */
10 bible?: number
11 /** Waits today that showed this verse (drives Read → Repeat → Recall → Check). */
12 views: number
13 /** Marked "Got it" in the Check stage. */
14 isMastered: boolean
15}
16
17/** Running totals kept across sessions in $.store. */
18export type WaitStats = {
19 /** Milliseconds spent waiting on Claude, all time. */
20 waitedMs: number
21 /** Milliseconds spent waiting on Claude, keyed by day. */
22 waitedByDay: Record<string, number>
23 /** Times each reference has been shown. */
24 reviews: Record<string, number>
25 /** Looks (waits that showed the verse), keyed by day. */
26 looksByDay: Record<string, number>
27 /** References marked mastered. */
28 mastered: string[]
29 /** Days (YYYY-MM-DD) with at least one wait, most recent last. */
30 activeDays: string[]
31}
32
33export type WaitStage = 'Read' | 'Repeat' | 'Recall' | 'Check'
34
35declare module 'claude-code' {
36 interface PluginState {
37 'wait-with-god': {
38 today: WaitToday | null
39 isRevealed: boolean
40 /** When `/wait hide` runs out (ms since epoch), or null when shown. */
41 hiddenUntil: number | null
42 /** What the last press of Share did, shown on the button for a moment; null at rest. */
43 shareResult: 'copied' | 'failed' | null
44 }
45 }
46}
47