The current prayer and how long is left, the next prayer, and zawal, above the prompt. Computed on your computer from your location; nothing is sent anywhere.

A private reference collection of public Claude Code mods, with a security-oriented analysis.
mods/Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.
hooks/register.tsx 355 lines1// Prayer Times: which prayer is it, and how long is left?
2// One line above the prompt: the current prayer and the time left to pray it, then the
3// next one; zawal and the makruh minutes after sunrise and before sunset in red. The times
4// are computed on this computer from the latitude and longitude in /config (the sun's
5// position, as prayer apps do it), so the location is never sent anywhere.
6// /prayers lists today's times. A toast says when a prayer begins, and again when its time
7// is nearly over.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import { bandsFile, parseOrder, stackBands } from './bands'
12
13const MIN = 60_000
14const SOON_MIN = 20 // the time left turns yellow under this many minutes
15const LAST_MIN = 5 // and red under this many
16
17// The sun's angle below the horizon for Fajr and Isha; Makkah's Isha is a fixed 90 minutes after Maghrib.
18const METHODS = {
19 karachi: { fajr: 18, isha: 18 },
20 mwl: { fajr: 18, isha: 17 },
21 isna: { fajr: 15, isha: 15 },
22 egypt: { fajr: 19.5, isha: 17.5 },
23 makkah: { fajr: 18.5, isha: 90 / 60, ishaIsMinutes: true },
24} as const
25
26export type Config = {
27 latitude: number
28 longitude: number
29 method: keyof typeof METHODS
30 asr: 'hanafi' | 'standard'
31 highLatitude: 'angle' | 'seventh' | 'middle'
32 adjust?: Partial<Record<Adjustable, number>> // minutes, to match a mosque's timetable
33}
34
35type Adjustable = 'fajr' | 'sunrise' | 'dhuhr' | 'asr' | 'sunset' | 'isha'
36
37// A day's times in hours after midnight UTC of that date (some may fall before 0 or past 24).
38export type Times = { fajr: number; sunrise: number; noon: number; dhuhr: number; asr: number; sunset: number; isha: number; estimated?: boolean }
39
40// Held by the host, so the line keeps ticking across a hot reload.
41const now = atom({ plugin: 'prayer-times', key: 'now' } as const, 0)
42
43let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
44
45export const register: Register = (on, options) => {
46 const cfg = configFrom(options)
47 const makruh = num(options.makruhMinutes, 15)
48 const zawal = num(options.zawalMinutes, 5)
49 const toasts = options.toasts !== false
50 const warnMinutes = Math.min(120, num(options.warnMinutes, 15))
51 let toasted = ''
52 let warned = ''
53
54 on('session.start', async ($, e, next) => {
55 const r = await next(e)
56 await $.command.register({ name: 'prayers', description: "Today's prayer times" }).catch(() => {})
57 const first = await $.clock.now()
58 await update($, now, () => first)
59 // The line moves each minute; a toast marks the start of each prayer, and another its last minutes.
60 $.clock.every(30_000, () => {
61 void (async () => {
62 const t = await $.clock.now()
63 await update($, now, () => t)
64 if (!cfg || !toasts) return
65 const s = status(cfg, new Date(t), makruh, zawal)
66 const key = `${s.current?.name}@${s.current?.start.toDateString()}`
67 if (s.current && t - s.current.start.getTime() < 60_000 && key !== toasted) {
68 toasted = key
69 $.ui.toast(`🕌 ${s.current.name} has begun · until ${hhmm(s.current.end)}`)
70 }
71 const due = deadlineDue(s, new Date(t), warnMinutes, warned, makruh)
72 if (due) {
73 warned = due.key
74 $.ui.toast(due.text)
75 }
76 })().catch(() => {})
77 })
78 return r
79 })
80
81 on('command.run', { command: 'prayers' }, async () => {
82 if (!cfg) return { text: 'Set your latitude and longitude in /config (prayer-times). They stay on this computer.' }
83 const d = new Date()
84 const t = timesFor(cfg, d)
85 const at = (h: number) => hhmm(atHours(d, h))
86 return {
87 text:
88 `Fajr ${at(t.fajr)} · Sunrise ${at(t.sunrise)} · Zawal ${at(t.noon - zawal / 60)} · Dhuhr ${at(t.dhuhr)} · ` +
89 `Asr ${at(t.asr)} · Maghrib ${at(t.sunset)} · Isha ${at(t.isha)} (${cfg.method}, ${cfg.asr} Asr` +
90 `${t.estimated ? ', no sunrise or sunset today: estimated from latitude 65°' : ''})`,
91 }
92 })
93
94 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
95 const rest = await next(e) // what other mods and Claude Code draw here stays
96 if (e.props.hasSurvey) return rest
97 const t = (await read($, now)) || Date.now() // subscribes the line to the clock
98 const { Box, Text } = $.ui.resolve(e)
99 if (!cfg) {
100 const ask = (
101 <Box paddingX={1}>
102 <Text dimColor wrap="truncate-end">{'🕌 prayer-times: set your latitude and longitude in /config. They stay on this computer'}</Text>
103 </Box>
104 )
105 return stackBands(rest, 'prayer-times', ask, 'above', await (bandOrder ??= readBandOrder($).catch(() => []))) // above what other mods draw, unless the band order says otherwise
106 }
107 const s = status(cfg, new Date(t), makruh, zawal)
108 const mine = (
109 <Box paddingX={1}>
110 <Text wrap="truncate-end">
111 {s.parts.map((p, i) => {
112 // A leading emoji is drawn plain: some terminals shift or clip a bold one.
113 const icon = /^(\p{Extended_Pictographic}\uFE0F?) /u.exec(p.text)?.[1]
114 const label = icon ? p.text.slice(icon.length) : p.text
115 return (
116 <Text>
117 {i > 0 && <Text dimColor>{' · '}</Text>}
118 {icon && <Text>{icon}</Text>}
119 <Text color={p.color} bold={p.bold} dimColor={p.dim}>{label}</Text>
120 </Text>
121 )
122 })}
123 </Text>
124 </Box>
125 )
126 return stackBands(rest, 'prayer-times', mine, 'above', await (bandOrder ??= readBandOrder($).catch(() => []))) // above what other mods draw, unless the band order says otherwise
127 })
128}
129
130// The band order file, as the claude-code-mods checklist saves it; [] on any error.
131async function readBandOrder($: EngineInterface): Promise<string[]> {
132 const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
133 return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
134}
135
136// Forgets the cached band order, for tests.
137export function resetBandOrder() {
138 bandOrder = undefined
139}
140
141const DHUHR_AFTER = 2 / 60 // Dhuhr starts this long after the sun's highest point
142
143type Part = { text: string; color?: string; bold?: boolean; dim?: boolean }
144type Window = { name: string; start: Date; end: Date }
145
146// What the line says at `at`: the prayer whose time it is and what is left, or the forbidden window, then the next prayer.
147export function status(cfg: Config, at: Date, makruh = 15, zawal = 5) {
148 // Times are UTC hours of each local date, so far from UTC (e.g. UTC+13) a day's prayers can sit two dates back.
149 const windows = [-2, -1, 0, 1].flatMap(offset => dayWindows(cfg, addDays(at, offset), makruh, zawal))
150 const inside = (w: Window) => w.start <= at && at < w.end
151 const prayers = windows.filter(w => !w.name.startsWith('!'))
152 const current = prayers.find(inside)
153 const blocked = windows.find(w => w.name.startsWith('!') && inside(w))
154 const next = prayers.find(w => w.start > at)!
155 const parts: Part[] = []
156
157 if (blocked) {
158 const label = blocked.name === '!zawal' ? 'Zawal' : blocked.name === '!sunrise' ? 'Sunrise' : 'Sunset near'
159 parts.push({ text: `⛔ ${label}`, color: 'red', bold: true })
160 if (blocked.name === '!sunset' && current) parts.push({ text: `${current.name} ${until(current.end, at)} left, makruh now`, color: 'red' })
161 else parts.push({ text: `no prayer for ${until(blocked.end, at)}`, color: 'red' })
162 } else if (current) {
163 const left = (current.end.getTime() - at.getTime()) / MIN
164 parts.push({ text: `🕌 ${current.name}`, bold: true })
165 parts.push({ text: `${until(current.end, at)} left`, color: left < LAST_MIN ? 'red' : left < SOON_MIN ? 'yellow' : 'green', bold: left < SOON_MIN })
166 } else {
167 parts.push({ text: `🕌 ${next.name} in ${until(next.start, at)}`, bold: true })
168 }
169 if (current || blocked) parts.push({ text: `next ${next.name} ${hhmm(next.start)}`, dim: true })
170 // Before Dhuhr, say when zawal comes.
171 const z = windows.find(w => w.name === '!zawal' && w.start > at)
172 if (z && next.name === 'Dhuhr' && !blocked) parts.push({ text: `zawal ${hhmm(z.start)}`, dim: true })
173 if (timesFor(cfg, at).estimated) parts.push({ text: 'estimated (no sunrise or sunset today)', dim: true })
174 return { parts, current, next, blocked }
175}
176
177// The warning that the current prayer's time is nearly over, once per window (`warned` is the last key warned).
178// It also comes when the session starts inside the last minutes, but not for a window no longer than the warning,
179// whose start toast already says when it ends. Asr warns too: its time runs to sunset, through the makruh minutes.
180// Asr's real deadline is the start of the makruh minutes before sunset, so it warns before those.
181export function deadlineDue(s: Pick<ReturnType<typeof status>, 'current'>, at: Date, warnMinutes: number, warned: string, makruh = 0) {
182 const w = s.current
183 if (!w || !(warnMinutes > 0)) return null
184 const key = `${w.name}@${w.start.getTime()}`
185 const end = w.name === 'Asr' && makruh > 0 ? new Date(w.end.getTime() - makruh * MIN) : w.end
186 const left = (end.getTime() - at.getTime()) / MIN
187 const length = (w.end.getTime() - w.start.getTime()) / MIN
188 if (key === warned || left <= 0 || left > warnMinutes || length <= warnMinutes) return null
189 if (end !== w.end) return { key, text: `⏳ Asr: makruh in ${Math.ceil(left)} min (${hhmm(end)}), sunset ${hhmm(w.end)}` }
190 return { key, text: `⏳ ${w.name} ends in ${Math.ceil(left)} min (${hhmm(w.end)})` }
191}
192
193// One day's windows: each prayer from its start to its end (Hanafi), and the times not to pray, marked with `!`.
194function dayWindows(cfg: Config, day: Date, makruh: number, zawal: number): Window[] {
195 const t = timesFor(cfg, day)
196 const tomorrow = timesFor(cfg, addDays(day, 1))
197 const at = (h: number) => atHours(day, h)
198 return [
199 { name: 'Fajr', start: at(t.fajr), end: at(t.sunrise) },
200 { name: '!sunrise', start: at(t.sunrise), end: at(t.sunrise + makruh / 60) },
201 { name: '!zawal', start: at(t.noon - zawal / 60), end: at(t.noon + DHUHR_AFTER) },
202 { name: 'Dhuhr', start: at(t.dhuhr), end: at(t.asr) },
203 { name: 'Asr', start: at(t.asr), end: at(t.sunset) },
204 { name: '!sunset', start: at(t.sunset - makruh / 60), end: at(t.sunset) },
205 { name: 'Maghrib', start: at(t.sunset), end: at(t.isha) },
206 { name: 'Isha', start: at(t.isha), end: at(24 + tomorrow.fajr) },
207 ]
208}
209
210// The day's times at the computer's own time zone for that date.
211// The times for the computer's local date, as UTC hours, so a daylight-saving change that day can't shift them.
212export function timesFor(cfg: Config, day: Date): Times {
213 const t = computeTimes(cfg, day.getFullYear(), day.getMonth() + 1, day.getDate(), 0)
214 for (const [k, m] of Object.entries(cfg.adjust ?? {})) t[k as Adjustable] += m / 60
215 return t
216}
217
218// `asr+2 isha-5` as minutes per time. Dhuhr moves Dhuhr's start only (zawal stays on the sun's highest point); Maghrib moves sunset.
219export function adjustFrom(text: string): Config['adjust'] {
220 const names: Record<string, Adjustable> = { fajr: 'fajr', sunrise: 'sunrise', dhuhr: 'dhuhr', asr: 'asr', maghrib: 'sunset', isha: 'isha' }
221 const out: Partial<Record<Adjustable, number>> = {}
222 for (const m of text.toLowerCase().matchAll(/(fajr|sunrise|dhuhr|asr|maghrib|isha)\s*([+-]?)\s*(\d+(?:\.\d+)?)/g)) out[names[m[1]!]!] = (m[2] === '-' ? -1 : 1) * Number(m[3])
223 return out
224}
225
226// The sun's position for the day, then the times it reaches each prayer's angle (the PrayTimes method).
227export function computeTimes(cfg: Config, year: number, month: number, day: number, zone: number): Times {
228 const t = solve(cfg, year, month, day, zone)
229 if (Number.isFinite(t.sunrise) && Number.isFinite(t.sunset)) return t
230 // Polar day or night: no sunrise or sunset at all. Use the nearest latitude that has them (65°), as many scholars advise.
231 return { ...solve({ ...cfg, latitude: Math.sign(cfg.latitude) * 65 }, year, month, day, zone), estimated: true }
232}
233
234function solve(cfg: Config, year: number, month: number, day: number, zone: number): Times {
235 const { latitude: lat, longitude: lng } = cfg
236 const m = METHODS[cfg.method]
237 const jdate = julian(year, month, day) - lng / (15 * 24)
238 const sun = (t: number) => sunPosition(jdate + t)
239 const midDay = (t: number) => fix(12 - sun(t).equation, 24)
240 const angleTime = (angle: number, t: number, before: boolean) => {
241 const decl = sun(t).declination
242 const noon = midDay(t)
243 const cos = (-sin(angle) - sin(decl) * sin(lat)) / (cos_(decl) * cos_(lat))
244 const span = acos(cos) / 15 // NaN when the sun never reaches the angle
245 return noon + (before ? -span : span)
246 }
247 const asrTime = (factor: number, t: number) => {
248 const decl = sun(t).declination
249 return angleTime(-acot(factor + tan(Math.abs(lat - decl))), t, false)
250 }
251 const factor = cfg.asr === 'hanafi' ? 2 : 1
252
253 // Two passes: the first from rough guesses, the second from the first's answers.
254 let h = { fajr: 5, sunrise: 6, noon: 12, asr: 13, sunset: 18, isha: 18 }
255 for (let pass = 0; pass < 2; pass++) {
256 const d = (x: number) => x / 24
257 h = {
258 fajr: angleTime(m.fajr, d(h.fajr), true),
259 sunrise: angleTime(0.833, d(h.sunrise), true),
260 noon: midDay(d(h.noon)),
261 asr: asrTime(factor, d(h.asr)),
262 sunset: angleTime(0.833, d(h.sunset), false),
263 isha: 'ishaIsMinutes' in m ? NaN : angleTime(m.isha, d(h.isha), false),
264 }
265 }
266 const shift = zone - lng / 15
267 const out: Times = { fajr: h.fajr + shift, sunrise: h.sunrise + shift, noon: h.noon + shift, dhuhr: h.noon + shift + DHUHR_AFTER, asr: h.asr + shift, sunset: h.sunset + shift, isha: h.isha + shift }
268 if ('ishaIsMinutes' in m) out.isha = out.sunset + m.isha
269
270 // Far from the equator the sun may never get low enough: Fajr and Isha take a share of the night instead.
271 const night = 24 - (out.sunset - out.sunrise)
272 const share = (angle: number) => (cfg.highLatitude === 'seventh' ? 1 / 7 : cfg.highLatitude === 'middle' ? 1 / 2 : angle / 60)
273 const fajrMax = share(m.fajr) * night
274 if (!Number.isFinite(out.fajr) || out.sunrise - out.fajr > fajrMax) out.fajr = out.sunrise - fajrMax
275 if (!('ishaIsMinutes' in m)) {
276 const ishaMax = share(m.isha) * night
277 if (!Number.isFinite(out.isha) || out.isha - out.sunset > ishaMax) out.isha = out.sunset + ishaMax
278 }
279 return out
280}
281
282function sunPosition(jd: number) {
283 const D = jd - 2451545
284 const g = fix(357.529 + 0.98560028 * D, 360)
285 const q = fix(280.459 + 0.98564736 * D, 360)
286 const L = fix(q + 1.915 * sin(g) + 0.02 * sin(2 * g), 360)
287 const e = 23.439 - 0.00000036 * D
288 const ra = atan2(cos_(e) * sin(L), cos_(L)) / 15
289 return { declination: asin(sin(e) * sin(L)), equation: q / 15 - fix(ra, 24) }
290}
291
292function julian(year: number, month: number, day: number) {
293 if (month <= 2) {
294 year -= 1
295 month += 12
296 }
297 const a = Math.floor(year / 100)
298 const b = 2 - a + Math.floor(a / 4)
299 return Math.floor(365.25 * (year + 4716)) + Math.floor(30.6001 * (month + 1)) + day + b - 1524.5
300}
301
302const rad = (d: number) => (d * Math.PI) / 180
303const deg = (r: number) => (r * 180) / Math.PI
304const sin = (d: number) => Math.sin(rad(d))
305const cos_ = (d: number) => Math.cos(rad(d))
306const tan = (d: number) => Math.tan(rad(d))
307const asin = (x: number) => deg(Math.asin(x))
308const acos = (x: number) => deg(Math.acos(x))
309const atan2 = (y: number, x: number) => deg(Math.atan2(y, x))
310const acot = (x: number) => deg(Math.atan(1 / x))
311const fix = (a: number, b: number) => {
312 const r = a - b * Math.floor(a / b)
313 return r < 0 ? r + b : r
314}
315
316function configFrom(options: Record<string, unknown>): Config | null {
317 const latitude = Number(options.latitude)
318 const longitude = Number(options.longitude)
319 const isSet = (v: unknown) => v !== undefined && v !== null && v !== ''
320 if (!isSet(options.latitude) || !isSet(options.longitude) || !(Math.abs(latitude) <= 90) || !(Math.abs(longitude) <= 180)) return null
321 const method = String(options.method ?? 'karachi') as Config['method']
322 return {
323 latitude,
324 longitude,
325 method: method in METHODS ? method : 'karachi',
326 asr: options.asr === 'standard' ? 'standard' : 'hanafi',
327 highLatitude: options.highLatitude === 'seventh' || options.highLatitude === 'middle' ? options.highLatitude : 'angle',
328 adjust: adjustFrom(String(options.adjust ?? '')),
329 }
330}
331
332function num(value: unknown, fallback: number) {
333 const n = Number(value)
334 return value === undefined || value === null || value === '' || !Number.isFinite(n) ? fallback : Math.max(0, n)
335}
336
337function addDays(d: Date, n: number) {
338 return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n, 12)
339}
340
341// The instant `hours` after midnight UTC of the day's local date.
342export function atHours(day: Date, hours: number) {
343 return new Date(Date.UTC(day.getFullYear(), day.getMonth(), day.getDate()) + Math.round(hours * 60) * MIN)
344}
345
346export function hhmm(d: Date) {
347 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
348}
349
350// Time left until `end`, rounded up to the minute: `42m`, `1h 05m`.
351export function until(end: Date, at: Date) {
352 const m = Math.max(0, Math.ceil((end.getTime() - at.getTime()) / MIN))
353 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
354}
355hooks/bands.ts 73 lines1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2// The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3// (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4// `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5// column, adds its own band and sorts again, so the last one to draw sorts them all.
6// Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7// Reading the file needs $, which Claude Code follows only within one file, so each mod
8// reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16 const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17 return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22 try {
23 const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24 return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25 } catch {
26 return []
27 }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38 const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39 const others = unpack(rest)
40 const items = at === 'above' ? [own, ...others] : [...others, own]
41 return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46 if (!rest) return []
47 if (!isColumn(rest)) return [rest]
48 const kids = rest.children ?? []
49 const owner = rest.part?.plugin
50 // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51 return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55 return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59 if (typeof node === 'string' || node.type !== 'Box') return undefined
60 const key = node.props?.key
61 return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65 const rank = (n: RenderNode) => {
66 const name = bandName(n)
67 if (name === undefined) return -2 // not a band: first
68 const i = order.indexOf(name)
69 return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70 }
71 return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73types/index.d.ts 8 lines1export type Tick = number // the last clock tick, so the line redraws each minute
2
3declare module 'claude-code' {
4 interface PluginState {
5 'prayer-times': { now: Tick }
6 }
7}
8