Standby readiness alert: flags when lottery_data.json is behind the draw schedule, with one-click refresh.

hooks/register.tsx 91 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Readiness } from '../types'
5import { check } from './ready'
6
7const DATA = 'lottery_data.json'
8const EVERY = 30 * 60_000
9
10const readiness = atom({ plugin: 'lotto-ready', key: 'readiness' } as const, null)
11const isRefreshing = atom({ plugin: 'lotto-ready', key: 'isRefreshing' } as const, false)
12
13async function runCheck($: EngineInterface, isQuiet = false): Promise<Readiness> {
14 let result: Readiness
15 try {
16 result = check(JSON.parse(await $.fs.read(DATA)), await $.clock.now())
17 } catch (err) {
18 result = { checkedAt: '', behind: [], next: '', error: `can't read ${DATA}` }
19 }
20 const was = await read($, readiness)
21 await update($, readiness, () => result)
22
23 if (result.error) {
24 $.ui.status(`🎰 lotto-ready: ${result.error}`)
25 } else if (result.behind.length) {
26 $.ui.status(`🎰 ⚠ ${result.behind.length} game(s) behind · /lotto-refresh`)
27 const isNew = !was || was.behind.length !== result.behind.length
28 if (isNew && !isQuiet) {
29 $.ui.toast(`Lotto data behind: ${result.behind.map(l => l.game).join(', ')}`)
30 }
31 } else {
32 $.ui.status(`🎰 Lotto ready ✓ · next: ${result.next}`)
33 }
34 return result
35}
36
37async function refresh($: EngineInterface): Promise<string> {
38 if (await read($, isRefreshing)) return 'Refresh already running.'
39 await update($, isRefreshing, () => true)
40 $.ui.status('🎰 refreshing lottery data…')
41 try {
42 const ran = await $.process.run(['python3', 'fetch_lottery.py'], { timeoutMs: 180_000 })
43 const after = await runCheck($, true)
44 if (ran.exitCode !== 0) return `fetch_lottery.py failed (exit ${ran.exitCode}): ${ran.stderr.slice(-300)}`
45 return after.behind.length
46 ? `Fetched, still behind: ${after.behind.map(l => `${l.game} (have ${l.have}, want ${l.want})`).join('; ')}`
47 : `Fetched. All games current ✓ Next: ${after.next}`
48 } finally {
49 await update($, isRefreshing, () => false)
50 }
51}
52
53function describe(r: Readiness): string {
54 if (r.error) return r.error
55 if (!r.behind.length) return `All 5 games current ✓ Next draw: ${r.next}`
56 return `Behind:\n${r.behind.map(l => `- ${l.game}: have ${l.have}, due ${l.want}`).join('\n')}\nRun /lotto-refresh.`
57}
58
59export const register: Register = on => {
60 on('session.start', async ($, e, next) => {
61 await $.command.register({ name: 'lotto-check', description: 'Check lottery data readiness now' })
62 await $.command.register({ name: 'lotto-refresh', description: 'Run fetch_lottery.py and re-check' })
63 void runCheck($)
64 $.clock.every(EVERY, () => void runCheck($))
65 return next(e)
66 })
67
68 on('command.run', { command: 'lotto-check' }, async $ => ({ text: describe(await runCheck($)) }))
69 on('command.run', { command: 'lotto-refresh' }, async $ => ({ text: await refresh($) }))
70
71 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
72 const r = await read($, readiness)
73 if (e.props.hasSurvey || !r || !r.behind.length) return next(e)
74
75 const { Box, Button, Text } = $.ui.resolve(e)
76 const busy = await read($, isRefreshing)
77 return (
78 <Box>
79 <Text color="yellow">
80 🎰 New draws ready: {r.behind.map(l => l.game).join(', ')}{' '}
81 </Text>
82 <Button
83 key="refresh"
84 label={busy ? 'Refreshing…' : 'Refresh now'}
85 onPress={() => void refresh($)}
86 />
87 </Box>
88 )
89 })
90}
91hooks/ready.ts 46 lines1import type { Lag, Readiness } from '../types'
2
3// Draw weekdays (0 = Sunday). Draws run ~22:30 ET, so results count as due
4// from 04:00 UTC the following day.
5export const SCHEDULE: Record<string, { label: string; days: number[] }> = {
6 lotto_max: { label: 'Lotto Max', days: [2, 5] },
7 western_max: { label: 'Western Max', days: [2, 5] },
8 lotto_649: { label: 'Lotto 6/49', days: [3, 6] },
9 western_649: { label: 'Western 6/49', days: [3, 6] },
10 daily_grand: { label: 'Daily Grand', days: [1, 4] },
11}
12
13const DAY = 86_400_000
14const DUE_AFTER = 28 * 3_600_000 // draw date midnight UTC + 28h = 04:00 UTC next day
15const WEEKDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
16
17const iso = (ms: number) => new Date(ms).toISOString().slice(0, 10)
18const midnight = (ms: number) => Math.floor(ms / DAY) * DAY
19
20export function latestDue(days: number[], now: number): string {
21 for (let d = midnight(now); ; d -= DAY) {
22 if (days.includes(new Date(d).getUTCDay()) && d + DUE_AFTER <= now) return iso(d)
23 }
24}
25
26export function nextDraw(now: number): string {
27 for (let d = midnight(now); ; d += DAY) {
28 if (d + DUE_AFTER <= now) continue
29 const day = new Date(d).getUTCDay()
30 const games = Object.values(SCHEDULE).filter(g => g.days.includes(day)).map(g => g.label)
31 if (games.length) return `${games.join(' + ')} ${WEEKDAY[day]}`
32 }
33}
34
35type Data = Record<string, { draws?: [string, ...unknown[]][] } | undefined>
36
37export function check(data: Data, now: number): Readiness {
38 const behind: Lag[] = []
39 for (const [key, { label, days }] of Object.entries(SCHEDULE)) {
40 const have = (data[key]?.draws ?? []).reduce((max, [date]) => (date > max ? date : max), '')
41 const want = latestDue(days, now)
42 if (have < want) behind.push({ game: label, have: have || 'none', want })
43 }
44 return { checkedAt: new Date(now).toISOString(), behind, next: nextDraw(now) }
45}
46types/index.d.ts 9 lines1export type Lag = { game: string; have: string; want: string }
2export type Readiness = { checkedAt: string; behind: Lag[]; next: string; error?: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'lotto-ready': { readiness: Readiness | null; isRefreshing: boolean }
7 }
8}
9