SLOPSHOPPER

Wait with God

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

newpanebandcommandtoastnetwork
★ 1v0.3.8no licenseupdated 2026-10-08ToastMobile/wait-with-god-plugins/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wait-with-god
│ ┃ version ✕ › fix the failing auth test and add an audit log call │ ┃ ✓ BSB Berean Standard Bible │ ┃ NIV New International Version ⏺ Read(src/auth.ts) │ ┃ NASB New American Standard Bible ⎿ Read 6 lines │ ┃ AMP Amplified Bible ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ The Berean Standard Bible is in the public d ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /wait │ ⎿ wait-with-god: Couldn't reach the Bible API. Wait with God will │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · version
✓ BSB Berean Standard Bible NIV New International Version NASB New American Standard Bible AMP Amplified Bible The Berean Standard Bible is in the public domain.
README

Wait with God

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 todayStepWhat you see
1-6ReadThe full verse
7-15RepeatA few words blanked, building to about a third
16-24RecallThose words and more cut to first letters, building to most
25+CheckEvery 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.

Install

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.

Commands

CommandWhat it does
/waitToday's verse and your totals: time redeemed today and all time, reviews, verses memorized, workday streak
/wait copyCopies the whole verse, never the blanked one
/wait nextSwitches today's verse to the next one you haven't memorized, from Read
/wait versionPicks a Bible version (the same as clicking it on the band); /wait version niv names one
/wait hideHides the band for an hour; /wait hide today for the rest of the day
/wait showShows it again
/wait resetRestarts today's verse at Read

/wait runs right away, even while Claude is working. Nothing counts while the band is hidden.

Privacy

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.

Source 3 files
hooks/register.tsx 487 lines
1import { 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}
487
hooks/verses.ts 320 lines
1import 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}
320
types/index.d.ts 47 lines
1/** 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