The next Muslim prayer time under Claude Code's prompt, a gentle reminder at adhan, and a pane with today's sky, the timetable, a weekly prayer tracker and the…

A small Claude Code mod that keeps the next prayer in view while you work.
Asr 4:18 PM, or Asr in 18 min within the hour.Asr now for 20 minutes after, with a Done button that also marks the prayer prayed. On Fridays Dhuhr is shown as Jumu'ah./prayer: a pane with the day at a glance:/config does not open.In the terminal the pane is the same, drawn in text.
Everything is calculated on your machine. It makes no network requests and sends nothing anywhere.
You need a Claude Code version with mods (function hooks), in the terminal or the desktop app's Code tab.
claude plugin marketplace add mkbuilds4/mods
claude plugin install prayer-times@mkbuilds
Start a new chat and the next prayer appears under the prompt.
Out of the box it guesses from your time zone: New York for US Eastern, London for UK time, Cairo for Egypt, and so on, with the method most used there. For accurate times, set your own coordinates (find them by right-clicking your city in Google Maps):
echo '{"latitude":"40.71","longitude":"-74.01","place":"New York"}' | claude plugin configure prayer-times@mkbuilds --values-stdin
Or change them in /config inside Claude Code, or from Location & settings in the /prayer pane, which takes a city name (Northern Virginia, Birmingham, Makkah) or coordinates (38.85, -77.31). What you set in the pane wins over /config; Use /config instead clears it. All the settings:
| Setting | What it does | Default |
|---|---|---|
latitude, longitude | Where to calculate for | guessed from your time zone |
place | The name shown in /prayer | the guessed city |
method | ISNA, MWL (Muslim World League), Egyptian, UmmAlQura, Karachi, or auto | auto (by region) |
asr | Standard (Shafi'i, Maliki, Hanbali) or Hanafi | Standard |
sound | chime, voice, or off | chime |
It uses the standard astronomical method from PrayTimes.org:
Daylight saving comes from your computer's clock for each date.
Checked against aladhan.com for the same location and method across a winter, a summer and an autumn day: 16 of 18 times matched to the minute, and the other two were one minute apart. That's about how much masjid timetables differ from each other anyway. If your masjid publishes its own timetable, follow it. Iqamah times are your masjid's, not astronomy's, so the mod doesn't try to show them.
claude plugin marketplace update mkbuilds
claude plugin update prayer-times@mkbuilds
Then start a new chat.
plugins/prayer-times/hooks/times.ts: the calculation, with no Claude Code dependencies. Ported from PrayTimes.js, so it's under the LGPL (see License).plugins/prayer-times/hooks/chime.ts: the chime, synthesized as a WAV in code (no sound files).plugins/prayer-times/hooks/art.ts: the pane's pictures (the sky, the icons, the week's rings, the compass), as SVG built in code.plugins/prayer-times/hooks/register.tsx: the footer label, reminder, pane and adhan notice.plugins/prayer-times/tests/render.test.tsx: draws the pane on every surface and checks marking a prayer and setting a place (claude plugin test plugins/prayer-times)./prayer pane draws the day's sky, marks prayers prayed with a weekly tracker, and sets your place by city name or coordinates.The mod is MIT, with one exception. The prayer time calculation, plugins/prayer-times/hooks/times.ts, is ported from PrayTimes.js by Hamid Zarrabi-Zadeh and stays under the original's GNU LGPL v3.0. You can use, change and share it freely; if you share a changed version of that file, keep it under the LGPL and keep the credit. Thanks to PrayTimes.org for publishing the method and the code that so many prayer apps are built on.
Made by MK Builds.
hooks/register.tsx 620 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { PrayerTimesPrefs as Prefs } from '../types'
5import { compass, hero, HERO_SIZE, icon, week, weekSize } from './art'
6import { chimeWav, toBase64 } from './chime'
7import {
8 clock,
9 compassPoint,
10 currentPrayer,
11 guessFromZone,
12 label,
13 METHOD_NAMES,
14 nextPrayer,
15 ORDER,
16 parseLocation,
17 PRAYERS,
18 prayerTimes,
19 qibla,
20 type AsrMethod,
21 type Method,
22 type PrayerName,
23 type Settings,
24} from './times'
25
26const PANE = 'prayer-times'
27// The reminder row shows from this long before adhan until this long after it.
28const BEFORE_MS = 15 * 60 * 1000
29const AFTER_MS = 20 * 60 * 1000
30// A prayer that began less than this long ago is announced (once, across chats).
31const ANNOUNCE_WINDOW_MS = 3 * 60 * 1000
32// How far the pane's day arrows go either way.
33const MAX_DAYS = 30
34
35const minute = atom({ plugin: 'prayer-times', key: 'minute' } as const, 0)
36const footer = atom({ plugin: 'prayer-times', key: 'footer' } as const, null as string | null)
37const done = atom({ plugin: 'prayer-times', key: 'done' } as const, [] as string[])
38const shownDay = atom({ plugin: 'prayer-times', key: 'day' } as const, 0)
39const prayed = atom({ plugin: 'prayer-times', key: 'prayed' } as const, {} as Record<string, string[]>)
40const prefs = atom({ plugin: 'prayer-times', key: 'prefs' } as const, {} as Prefs)
41const editing = atom({ plugin: 'prayer-times', key: 'editing' } as const, false)
42const notice = atom({ plugin: 'prayer-times', key: 'notice' } as const, '')
43
44type Sound = 'chime' | 'voice' | 'off'
45type Config = { settings: Settings; place: string; source: 'pane' | 'settings' | 'zone'; sound: Sound }
46
47const dayKey = (at: Date) =>
48 `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')}`
49
50const until = (at: Date, now: Date) => {
51 const minutes = Math.ceil((at.getTime() - now.getTime()) / 60000)
52 return minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
53}
54
55const hijri = (at: Date) => {
56 try {
57 return new Intl.DateTimeFormat('en-u-ca-islamic-umalqura', { day: 'numeric', month: 'long', year: 'numeric' }).format(at)
58 } catch {
59 return null
60 }
61}
62
63const zone = () => {
64 try {
65 return Intl.DateTimeFormat().resolvedOptions().timeZone
66 } catch {
67 return ''
68 }
69}
70
71let chime: string | null = null
72// The plugin's /config values, as it loaded.
73let options: Record<string, unknown> = {}
74
75// The settings in force: what was set in the pane, else in /config, else a guess
76// from the machine's time zone.
77function configFor(p: Prefs): Config {
78 const guess = guessFromZone(zone())
79 const latitude = Number(options.latitude) || 0
80 const longitude = Number(options.longitude) || 0
81 const source: Config['source'] =
82 typeof p.latitude === 'number' && typeof p.longitude === 'number'
83 ? 'pane'
84 : latitude === 0 && longitude === 0
85 ? 'zone'
86 : 'settings'
87 const at =
88 source === 'pane' ? { latitude: p.latitude!, longitude: p.longitude! } : source === 'settings' ? { latitude, longitude } : guess
89 const coords = `${at.latitude.toFixed(2)}, ${at.longitude.toFixed(2)}`
90 const place = source === 'pane' ? p.place || coords : source === 'settings' ? String(options.place || '') || coords : guess.city
91 const chosen = String(p.method ?? options.method ?? 'auto')
92 const method = (chosen in METHOD_NAMES ? chosen : guess.method) as Method
93 const asr: AsrMethod = (p.asr ?? options.asr) === 'Hanafi' ? 'Hanafi' : 'Standard'
94 const sound = String(p.sound ?? options.sound)
95 return {
96 settings: { latitude: at.latitude, longitude: at.longitude, method, asr },
97 place,
98 source,
99 sound: sound === 'voice' || sound === 'off' ? sound : 'chime',
100 }
101}
102
103const statusText = (now: Date, config: Config) => {
104 const next = nextPrayer(now, config.settings)
105 const soon = next.at.getTime() - now.getTime() < 60 * 60 * 1000
106 return soon ? `${label(next.name, next.at)} in ${until(next.at, now)}` : `${label(next.name, next.at)} ${clock(next.at)}`
107}
108
109const dayFrom = (at: Date, offset: number) => new Date(at.getFullYear(), at.getMonth(), at.getDate() + offset)
110
111// Whose time it is now: Fajr runs to sunrise, Isha to the next Fajr, and between
112// sunrise and Dhuhr it is no prayer's.
113function windowAt(now: Date, s: Settings) {
114 const today = prayerTimes(now, s)
115 const at = (name: PrayerName, start: Date, end: Date) => ({ name, start, end })
116 if (now < today.Fajr) return at('Isha', prayerTimes(dayFrom(now, -1), s).Isha, today.Fajr)
117 if (now < today.Sunrise) return at('Fajr', today.Fajr, today.Sunrise)
118 if (now < today.Dhuhr) return null
119 if (now < today.Asr) return at('Dhuhr', today.Dhuhr, today.Asr)
120 if (now < today.Maghrib) return at('Asr', today.Asr, today.Maghrib)
121 if (now < today.Isha) return at('Maghrib', today.Maghrib, today.Isha)
122 return at('Isha', today.Isha, prayerTimes(dayFrom(now, 1), s).Fajr)
123}
124
125// The prayer time the wait for the next one began at: today's, or yesterday's Isha.
126function previousAt(now: Date, s: Settings) {
127 const today = prayerTimes(now, s)
128 const begun = PRAYERS.map(name => today[name]).filter(at => at <= now)
129 return begun[begun.length - 1] ?? prayerTimes(dayFrom(now, -1), s).Isha
130}
131
132// The pane's settings live in the store, so they outlast the session; state
133// carries them to the drawings.
134async function setPrefs($: EngineInterface, change: (p: Prefs) => Prefs) {
135 const value = change(((await $.store.get('prefs')) ?? {}) as Prefs)
136 await $.store.set('prefs', value)
137 await update($, prefs, () => value)
138 await update($, footer, () => statusText(new Date(), configFor(value)))
139}
140
141async function setLocation($: EngineInterface, text: string) {
142 const place = parseLocation(text)
143 if (!place) {
144 await update($, notice, () => `Couldn't place "${text.trim()}". Try a city, or latitude, longitude such as 38.85, -77.31.`)
145 return
146 }
147 await update($, notice, () => '')
148 await setPrefs($, p => ({ ...p, ...place }))
149}
150
151// Marks a prayer prayed (or not), in the store as the record of it.
152async function markPrayed($: EngineInterface, day: string, name: PrayerName, isPrayed?: boolean) {
153 const stored = ((await $.store.get('prayed')) ?? {}) as Record<string, string[]>
154 const list = stored[day] ?? []
155 const want = isPrayed ?? !list.includes(name)
156 const marked = want ? [...new Set([...list, name])] : list.filter(one => one !== name)
157 // Two months is more than the week's rings need, and keeps the store small.
158 const cutoff = dayKey(new Date(Date.now() - 60 * 86400000))
159 const value = Object.fromEntries(
160 Object.entries({ ...stored, [day]: marked }).filter(([key, names]) => key >= cutoff && names.length > 0),
161 )
162 await $.store.set('prayed', value)
163 await update($, prayed, () => value)
164}
165
166// Another chat may have marked a prayer or moved the location since: the store
167// is the record, state a copy of it.
168async function sync($: EngineInterface) {
169 const storedPrayed = ((await $.store.get('prayed')) ?? {}) as Record<string, string[]>
170 if (JSON.stringify(storedPrayed) !== JSON.stringify(await read($, prayed))) await update($, prayed, () => storedPrayed)
171 const storedPrefs = ((await $.store.get('prefs')) ?? {}) as Prefs
172 if (JSON.stringify(storedPrefs) !== JSON.stringify(await read($, prefs))) await update($, prefs, () => storedPrefs)
173}
174
175async function playReminder($: EngineInterface, config: Config, text: string) {
176 try {
177 if (config.sound === 'chime') {
178 chime ??= toBase64(chimeWav())
179 await $.audio.play({ base64: chime, mime: 'audio/wav' })
180 } else if (config.sound === 'voice') {
181 await $.audio.speak(text)
182 }
183 } catch {
184 // No audio device: the notice is enough.
185 }
186}
187
188// Every chat checks; the first to see a new prayer claims it in the shared
189// store, so only one plays the sound.
190async function tick($: EngineInterface) {
191 const now = new Date(await $.clock.now())
192 const stamp = Math.floor(now.getTime() / 60000)
193 if ((await read($, minute)) !== stamp) {
194 await update($, minute, () => stamp)
195 await sync($)
196 }
197 const config = configFor(await read($, prefs))
198 const text = statusText(now, config)
199 if ((await read($, footer)) !== text) await update($, footer, () => text)
200
201 const current = currentPrayer(now, config.settings, ANNOUNCE_WINDOW_MS)
202 if (!current) return
203 const key = `announced:${dayKey(current.at)}:${current.name}`
204 if (await $.store.get(key)) return
205 await $.store.set(key, now.getTime())
206 const name = label(current.name, current.at)
207 $.ui.toast(`Time for ${name} (${clock(current.at)})`)
208 await playReminder($, config, `Time for ${name}`)
209}
210
211// Everything the pane draws, worked out once for both of its drawings.
212function paneData(now: Date, offset: number, config: Config, marks: Record<string, string[]>) {
213 const s = config.settings
214 const shown = dayFrom(now, offset)
215 const times = prayerTimes(shown, s)
216 const key = dayKey(shown)
217 const isToday = offset === 0
218 const upcoming = nextPrayer(now, s)
219 const nextName = isToday && dayKey(upcoming.at) === key ? upcoming.name : null
220 const period = isToday ? windowAt(now, s) : null
221 const nowName = period && dayKey(period.start) === key ? period.name : null
222 const marked = marks[key] ?? []
223
224 const rows = PRAYERS.map(name => {
225 const at = times[name]
226 const hasBegun = offset < 0 || (isToday && at <= now)
227 return {
228 name,
229 at,
230 label: label(name, at),
231 time: clock(at),
232 isNext: name === nextName,
233 isNow: name === nowName,
234 isPast: hasBegun && name !== nowName,
235 hasBegun,
236 isPrayed: marked.includes(name),
237 }
238 })
239
240 const weekDays = Array.from({ length: 7 }, (_, i) => {
241 const day = dayFrom(now, i - 6)
242 const count = (marks[dayKey(day)] ?? []).filter(name => (PRAYERS as string[]).includes(name)).length
243 return {
244 letter: day.toLocaleDateString([], { weekday: 'narrow' }),
245 count,
246 isToday: i === 6,
247 title: `${day.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' })} · ${count} of 5 prayed`,
248 }
249 })
250
251 const daylight = Math.round((times.Maghrib.getTime() - times.Sunrise.getTime()) / 60000)
252 const nextLabel = label(upcoming.name, upcoming.at)
253 const isTomorrow = dayKey(upcoming.at) !== dayKey(now)
254 const card = isToday
255 ? {
256 eyebrow: 'Next prayer',
257 heading: nextLabel,
258 detail: `${clock(upcoming.at)}${isTomorrow ? ' tomorrow' : ''} · in ${until(upcoming.at, now)}`,
259 progress: (() => {
260 const from = previousAt(now, s).getTime()
261 return (now.getTime() - from) / Math.max(1, upcoming.at.getTime() - from)
262 })(),
263 pill: period
264 ? period.name === 'Fajr'
265 ? `Fajr ends at sunrise, ${clock(period.end)}`
266 : `${label(period.name, period.start)} time`
267 : null,
268 }
269 : {
270 eyebrow: offset > 0 ? 'Looking ahead' : 'Looking back',
271 heading: offset === 1 ? 'Tomorrow' : offset === -1 ? 'Yesterday' : offset > 0 ? `In ${offset} days` : `${-offset} days ago`,
272 detail: `Sunrise ${clock(times.Sunrise)} · Maghrib ${clock(times.Maghrib)}`,
273 progress: null,
274 pill: `${Math.floor(daylight / 60)}h ${String(daylight % 60).padStart(2, '0')}m of daylight`,
275 }
276
277 const q = qibla(s.latitude, s.longitude)
278 const methodName = METHOD_NAMES[s.method]
279 return {
280 shown,
281 key,
282 times,
283 isToday,
284 rows,
285 weekDays,
286 weekTotal: weekDays.reduce((sum, d) => sum + d.count, 0),
287 card,
288 nextLabel,
289 upcoming,
290 nextName,
291 date: shown.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }),
292 islamic: hijri(shown),
293 qibla: { degrees: Math.round(q.bearing), point: compassPoint(q.bearing), bearing: q.bearing, km: Math.round(q.km) },
294 placeLine: `${config.place}${config.source === 'zone' ? ' (from your time zone)' : ''} · ${methodName} · Asr ${s.asr}`,
295 }
296}
297
298const METHOD_OPTIONS = [
299 { value: 'auto', label: 'Auto: the one most used in your time zone' },
300 ...Object.entries(METHOD_NAMES).map(([value, name]) => ({ value, label: name })),
301]
302const ASR_OPTIONS = [
303 { value: 'Standard', label: "Standard (Shafi'i, Maliki, Hanbali)" },
304 { value: 'Hanafi', label: 'Hanafi' },
305]
306const SOUND_OPTIONS = [
307 { value: 'chime', label: 'A soft chime' },
308 { value: 'voice', label: 'A spoken reminder' },
309 { value: 'off', label: 'Nothing' },
310]
311
312type Controls = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Input' | 'Select'>
313
314// The pane's own settings: what /config holds, reachable where /config is not
315// (the desktop app), and winning over it once set here.
316function settingsEditor($: EngineInterface, { Box, Text, Button, Input, Select }: Controls, config: Config, p: Prefs, message: string) {
317 return (
318 <Box flexDirection="column" gap={1}>
319 <Input
320 key="location"
321 label="Location"
322 placeholder="A city, or latitude, longitude"
323 submitLabel="set"
324 onSubmit={value => void setLocation($, value)}
325 />
326 {message ? <Text color="yellow">{message}</Text> : <Text dimColor>Now: {config.place}</Text>}
327 <Select
328 key="method"
329 label="Method"
330 options={METHOD_OPTIONS}
331 value={String(p.method ?? options.method ?? 'auto')}
332 onSelect={value => void setPrefs($, current => ({ ...current, method: value }))}
333 />
334 <Select
335 key="asr"
336 label="Asr"
337 options={ASR_OPTIONS}
338 value={config.settings.asr}
339 onSelect={value => void setPrefs($, current => ({ ...current, asr: value }))}
340 />
341 <Select
342 key="sound"
343 label="At adhan"
344 options={SOUND_OPTIONS}
345 value={config.sound}
346 onSelect={value => void setPrefs($, current => ({ ...current, sound: value }))}
347 />
348 <Box gap={1} flexWrap="wrap">
349 <Button key="hear" label="Play it" onPress={() => void playReminder($, config, 'Time for prayer')} />
350 {Object.keys(p).length > 0 && (
351 <Button
352 key="reset"
353 label="Use /config instead"
354 onPress={() => {
355 void update($, notice, () => '')
356 void setPrefs($, () => ({}))
357 }}
358 />
359 )}
360 <Button key="close-settings" label="Done" variant="primary" onPress={() => void update($, editing, () => false)} />
361 </Box>
362 </Box>
363 )
364}
365
366export const register: Register = (on, loaded) => {
367 options = loaded
368
369 // Shown among the prompt footer's labels, where no plugin name is drawn beside it.
370 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
371 const text = await read($, footer)
372 return text ? next({ ...e, props: { ...e.props, modes: [...e.props.modes, text] } }) : next(e)
373 })
374
375 on('session.start', async ($, e, next) => {
376 await $.command.register({ name: 'prayer', description: "Today's prayer times" })
377 await sync($)
378 void tick($)
379 $.clock.every(20 * 1000, () => void tick($))
380
381 return next(e)
382 })
383
384 on('command.run', { command: 'prayer' }, async ($, e) => {
385 await update($, shownDay, () => 0)
386 await $.ui.open({ id: PANE, title: 'Prayer times' })
387
388 return { text: 'Prayer times opened.' }
389 })
390
391 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
392 await read($, minute)
393 const dismissed = await read($, done)
394 const config = configFor(await read($, prefs))
395 const now = new Date()
396 const upcoming = nextPrayer(now, config.settings)
397 const current = currentPrayer(now, config.settings, AFTER_MS)
398 const soon = upcoming.at.getTime() - now.getTime() <= BEFORE_MS
399 const showing = current && !dismissed.includes(`${dayKey(current.at)}:${current.name}`) ? current : soon ? upcoming : null
400 if (e.props.hasSurvey || !showing) return next(e)
401
402 const { Box, Button, Text } = $.ui.resolve(e)
403 const below = await next(e)
404 const isNow = showing === current
405 const name = label(showing.name, showing.at)
406 const id = `${dayKey(showing.at)}:${showing.name}`
407
408 return (
409 <Box flexDirection="column">
410 <Box gap={1} alignItems="center" marginBottom={1}>
411 <Text color={isNow ? 'green' : 'yellow'}>●</Text>
412 <Text bold>{isNow ? `${name} now` : `${name} in ${until(showing.at, now)}`}</Text>
413 <Text dimColor>· {clock(showing.at)}</Text>
414 {isNow && (
415 <Button
416 key="prayer-done"
417 label="Done"
418 plain
419 dimColor
420 onPress={async () => {
421 await update($, done, list => [...list, id].slice(-20))
422 await markPrayed($, dayKey(showing.at), showing.name, true)
423 }}
424 />
425 )}
426 </Box>
427 {below}
428 </Box>
429 )
430 })
431
432 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
433 await read($, minute)
434 const offset = await read($, shownDay)
435 const marks = await read($, prayed)
436 const p = await read($, prefs)
437 const isEditing = await read($, editing)
438 const message = await read($, notice)
439 const config = configFor(p)
440 const now = new Date()
441 const d = paneData(now, offset, config, marks)
442
443 const step = (by: number) => () => void update($, shownDay, day => Math.max(-MAX_DAYS, Math.min(MAX_DAYS, day + by)))
444 const toggle = (row: (typeof d.rows)[number]) => () => {
445 if (!row.hasBegun) {
446 $.ui.toast(`${row.label} hasn't begun yet`)
447 return
448 }
449 void markPrayed($, d.key, row.name)
450 }
451
452 // The terminal draws no pictures: the same pane in text.
453 if (e.surface === 'terminal') {
454 const ui = $.ui.resolve(e)
455 const { Box, Text, Button } = ui
456 return (
457 <Box flexDirection="column" gap={1}>
458 <Box justifyContent="space-between" flexWrap="wrap" gap={1}>
459 <Box flexDirection="column">
460 <Text bold>{d.date}</Text>
461 {d.islamic && <Text dimColor>{d.islamic}</Text>}
462 </Box>
463 <Box gap={1}>
464 <Button key="day-back" label="‹" plain onPress={step(-1)} />
465 {offset !== 0 && <Button key="day-today" label="today" plain onPress={() => void update($, shownDay, () => 0)} />}
466 <Button key="day-forward" label="›" plain onPress={step(1)} />
467 </Box>
468 </Box>
469
470 <Box flexDirection="column">
471 <Text bold color={d.isToday ? 'green' : undefined}>
472 {d.card.heading}
473 {d.isToday ? ` in ${until(d.upcoming.at, now)}` : ''}
474 </Text>
475 <Text dimColor>{d.isToday ? `${clock(d.upcoming.at)}${d.card.pill ? ` · ${d.card.pill}` : ''}` : d.card.detail}</Text>
476 </Box>
477
478 <Box flexDirection="column">
479 {d.rows.map(row => [
480 <Box key={`row-${row.name}`} gap={2}>
481 <Text bold={row.isNext || row.isNow} color={row.isNext ? 'green' : undefined} dimColor={row.isPast}>
482 {(row.isNext ? '▸ ' : row.isNow ? '• ' : ' ') + row.label.padEnd(9)}
483 </Text>
484 <Text bold={row.isNext} color={row.isNext ? 'green' : undefined} dimColor={row.isPast}>
485 {row.time.padStart(8)}
486 </Text>
487 <Button key={`pray-${row.name}`} label={row.isPrayed ? '✓' : '○'} plain dimColor={!row.isPrayed} onPress={toggle(row)} />
488 </Box>,
489 row.name === 'Fajr' && (
490 <Box key="row-Sunrise" gap={2}>
491 <Text dimColor>{' ' + 'Sunrise'.padEnd(9)}</Text>
492 <Text dimColor>{clock(d.times.Sunrise).padStart(8)}</Text>
493 </Box>
494 ),
495 ])}
496 </Box>
497
498 <Box gap={1} flexWrap="wrap">
499 <Text dimColor>This week</Text>
500 {d.weekDays.map((day, i) => (
501 <Text key={`week-${i}`} bold={day.isToday} color={day.count >= 5 ? 'green' : undefined} dimColor={day.count === 0}>
502 {`${day.letter}${day.count >= 5 ? '✓' : day.count}`}
503 </Text>
504 ))}
505 </Box>
506
507 <Box flexDirection="column">
508 <Text dimColor>
509 Qibla {d.qibla.degrees}° {d.qibla.point} · {d.qibla.km.toLocaleString()} km to Makkah
510 </Text>
511 <Text dimColor>{d.placeLine}</Text>
512 </Box>
513 {isEditing ? (
514 settingsEditor($, ui, config, p, message)
515 ) : (
516 <Button key="open-settings" label="Location & settings" plain dimColor onPress={() => void update($, editing, () => true)} />
517 )}
518 </Box>
519 )
520 }
521
522 const { Box, Text, Button, Svg } = $.ui.resolve(e)
523 const labels = Object.fromEntries(ORDER.map(name => [name, label(name, d.times[name])])) as Record<PrayerName, string>
524 const clocks = Object.fromEntries(ORDER.map(name => [name, clock(d.times[name])])) as Record<PrayerName, string>
525 const sky = hero({
526 day: d.shown,
527 now: d.isToday ? now : null,
528 latitude: config.settings.latitude,
529 longitude: config.settings.longitude,
530 times: d.times,
531 labels,
532 clocks,
533 next: d.nextName,
534 ...d.card,
535 })
536 const skyAlt = d.isToday
537 ? `The sun's path across today's sky. Next prayer: ${d.nextLabel} at ${clock(d.upcoming.at)}, in ${until(d.upcoming.at, now)}.`
538 : `The sun's path across the sky on ${d.date}, with each prayer time marked.`
539
540 return (
541 <Box flexDirection="column" gap={1}>
542 <Box justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={1}>
543 <Box flexDirection="column">
544 <Text bold>{d.date}</Text>
545 {d.islamic && <Text dimColor>{d.islamic}</Text>}
546 </Box>
547 <Box gap={1}>
548 <Button key="day-back" label="‹" onPress={step(-1)} />
549 {offset !== 0 && <Button key="day-today" label="Today" onPress={() => void update($, shownDay, () => 0)} />}
550 <Button key="day-forward" label="›" onPress={step(1)} />
551 </Box>
552 </Box>
553
554 <Svg source={sky} alt={skyAlt} width={HERO_SIZE.width} height={HERO_SIZE.height} isInteractive />
555
556 <Box flexDirection="column">
557 {d.rows.map(row => [
558 <Box key={`row-${row.name}`} gap={1} alignItems="center">
559 <Svg source={icon(row.name, row.isPast)} alt={row.label} width={18} height={18} />
560 <Box flexGrow={1} gap={1}>
561 <Text bold={row.isNext || row.isNow} color={row.isNext ? 'green' : undefined} dimColor={row.isPast}>
562 {row.label}
563 </Text>
564 {row.isNow && <Text color="green">now</Text>}
565 {row.isNext && <Text dimColor>in {until(row.at, now)}</Text>}
566 </Box>
567 <Text bold={row.isNext} color={row.isNext ? 'green' : undefined} dimColor={row.isPast}>
568 {row.time}
569 </Text>
570 <Button key={`pray-${row.name}`} label={row.isPrayed ? '✓' : '○'} plain dimColor={!row.isPrayed} onPress={toggle(row)} />
571 </Box>,
572 row.name === 'Fajr' && (
573 <Box key="row-Sunrise" gap={1} alignItems="center">
574 <Svg source={icon('Sunrise', d.isToday ? d.times.Sunrise <= now : offset < 0)} alt="Sunrise" width={18} height={18} />
575 <Text dimColor>Sunrise {clock(d.times.Sunrise)}</Text>
576 </Box>
577 ),
578 ])}
579 </Box>
580
581 <Box flexDirection="column">
582 <Box gap={1}>
583 <Text bold>This week</Text>
584 <Text dimColor>{d.weekTotal} of 35 prayed</Text>
585 </Box>
586 <Svg
587 source={week(d.weekDays)}
588 alt={`Prayers marked this week: ${d.weekDays.map(day => `${day.letter} ${day.count}`).join(', ')}`}
589 width={weekSize(d.weekDays.length).width}
590 height={weekSize(d.weekDays.length).height}
591 isInteractive
592 />
593 {d.weekTotal === 0 && <Text dimColor>Press ○ beside a prayer once you've prayed it, and the rings fill.</Text>}
594 </Box>
595
596 <Box gap={2} alignItems="center">
597 <Svg source={compass(d.qibla.bearing)} alt={`Qibla ${d.qibla.degrees}° ${d.qibla.point}`} width={48} height={48} />
598 <Box flexDirection="column" flexGrow={1}>
599 <Box gap={1}>
600 <Text>Qibla</Text>
601 <Text bold>
602 {d.qibla.degrees}° {d.qibla.point}
603 </Text>
604 <Text dimColor>· {d.qibla.km.toLocaleString()} km to Makkah</Text>
605 </Box>
606 <Text dimColor>{d.placeLine}</Text>
607 </Box>
608 </Box>
609
610 {e.surface !== 'mobile' &&
611 (isEditing ? (
612 settingsEditor($, $.ui.resolve(e), config, p, message)
613 ) : (
614 <Button key="open-settings" label="Location & settings" plain dimColor onPress={() => void update($, editing, () => true)} />
615 ))}
616 </Box>
617 )
618 })
619}
620hooks/art.ts 298 lines1// The pane's pictures, as SVG source for the `Svg` element. Pure: no engine
2// calls, so each can be drawn in a browser on its own while working on it.
3
4import { moonAge, ORDER, sunAltitude, type PrayerName } from './times'
5
6const FONT = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif`
7
8// The desktop draws an interactive Svg in a sandboxed frame of its own. When the
9// app is dark and the frame's document names no colour scheme, the browser backs
10// the frame with opaque white; naming both keeps it transparent in either theme.
11const SCHEME = ':root{color-scheme:light dark}'
12
13const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
14const n = (v: number) => Math.round(v * 10) / 10
15
16type RGB = [number, number, number]
17const rgb = (hex: string): RGB => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)) as RGB
18const mix = (a: string, b: string, t: number) => {
19 const [x, y] = [rgb(a), rgb(b)]
20 return `rgb(${x.map((v, i) => Math.round(v + ((y[i] ?? v) - v) * t)).join(',')})`
21}
22
23// The sky's top and horizon colours by the sun's altitude: night, the blue
24// hour, the pink and orange of twilight, golden hour, day.
25const SKY: Array<[number, string, string]> = [
26 [-18, '#060a1c', '#0e1734'],
27 [-12, '#0a1230', '#1f2a58'],
28 [-6, '#172150', '#5b4378'],
29 [-2, '#28376c', '#c56a78'],
30 [0, '#38548f', '#ec9466'],
31 [4, '#4673b3', '#f2b985'],
32 [10, '#4b8bd3', '#a7d0f0'],
33 [90, '#3a82d6', '#9ccdf4'],
34]
35
36const skyAt = (alt: number) => {
37 const a = Math.max(-18, Math.min(90, alt))
38 for (let i = 1; i < SKY.length; i++) {
39 const [hi, top, low] = SKY[i]!
40 const [lo, top0, low0] = SKY[i - 1]!
41 if (a <= hi) {
42 const t = (a - lo) / (hi - lo)
43 return { top: mix(top0, top, t), horizon: mix(low0, low, t) }
44 }
45 }
46 return { top: SKY[SKY.length - 1]![1], horizon: SKY[SKY.length - 1]![2] }
47}
48
49// A small deterministic generator, so the stars stay put from one redraw to the next.
50const seeded = (seed: number) => () => {
51 seed = (seed * 1664525 + 1013904223) % 4294967296
52 return seed / 4294967296
53}
54
55// The lit part of the moon `age` days after new, as a path: the limb on the lit
56// side and the terminator, an ellipse, back. Waxing is lit on the right.
57const moonPath = (cx: number, cy: number, r: number, age: number) => {
58 const phase = age / 29.530588853
59 const k = Math.cos(2 * Math.PI * phase)
60 const rx = n(Math.abs(k) * r)
61 const waxing = phase < 0.5
62 const limb = waxing ? 1 : 0
63 const terminator = waxing ? (k > 0 ? 0 : 1) : k > 0 ? 1 : 0
64 return `M${cx} ${cy - r} A${r} ${r} 0 0 ${limb} ${cx} ${cy + r} A${rx} ${r} 0 0 ${terminator} ${cx} ${cy - r}Z`
65}
66
67export type HeroArgs = {
68 // Local midnight of the day drawn.
69 day: Date
70 // Where the sun is marked: today only.
71 now: Date | null
72 latitude: number
73 longitude: number
74 times: Record<PrayerName, Date>
75 labels: Record<PrayerName, string>
76 clocks: Record<PrayerName, string>
77 next: PrayerName | null
78 eyebrow: string
79 heading: string
80 detail: string
81 // Elapsed share of the wait for the next prayer, 0 to 1; null draws no bar.
82 progress: number | null
83 pill: string | null
84}
85
86const W = 400
87const H = 236
88const HORIZON = 182
89const LEFT = 14
90const RIGHT = W - 14
91
92// The frame an interactive Svg is drawn in takes the element's width and height
93// props, and without them is a default iframe, 150px tall. Each drawing's own
94// size, then, to pass there. A narrower pane shrinks the frame's width, and the
95// drawing scales down into its top left corner.
96export const HERO_SIZE = { width: W, height: H }
97
98// The day's sky: the sun's real path across it, each prayer where the sun stands
99// at its time, and the sun (or the moon) now.
100export const hero = (a: HeroArgs) => {
101 const start = a.day.getTime()
102 const x = (t: number) => LEFT + ((t - start) / 86400000) * (RIGHT - LEFT)
103 const samples = Array.from({ length: 121 }, (_, i) => {
104 const t = start + i * 12 * 60000
105 return { t, alt: sunAltitude(new Date(t), a.latitude, a.longitude) }
106 })
107 const peak = Math.max(5, ...samples.map(s => s.alt))
108 const up = 74 / peak
109 const down = Math.min(up, 2.1)
110 const y = (alt: number) => (alt >= 0 ? HORIZON - alt * up : HORIZON - alt * down)
111 const line = (points: Array<{ t: number; alt: number }>) =>
112 points.map((p, i) => `${i ? 'L' : 'M'}${n(x(p.t))} ${n(y(p.alt))}`).join('')
113
114 const nowAlt = a.now ? sunAltitude(a.now, a.latitude, a.longitude) : 35
115 const sky = skyAt(nowAlt)
116 const dark = Math.max(0, Math.min(1, (-nowAlt - 3) / 9))
117
118 const random = seeded(Math.floor(start / 86400000))
119 const stars = dark
120 ? Array.from({ length: 34 }, (_, i) => {
121 const sx = n(8 + random() * (W - 16))
122 const sy = n(6 + random() * (HORIZON - 30))
123 const r = n(0.5 + random() * 0.9)
124 const o = n((0.35 + random() * 0.6) * dark)
125 const twinkle =
126 i % 3 === 0
127 ? `<animate attributeName="opacity" values="${o};${n(o * 0.25)};${o}" dur="${n(2.5 + random() * 3)}s" begin="${n(random() * 3)}s" repeatCount="indefinite"/>`
128 : ''
129 return `<circle cx="${sx}" cy="${sy}" r="${r}" fill="#fff" opacity="${o}">${twinkle}</circle>`
130 }).join('')
131 : ''
132
133 const moon =
134 a.now && dark > 0.2
135 ? `<g opacity="${n(0.35 + 0.65 * dark)}"><circle cx="356" cy="70" r="20" fill="url(#moonGlow)"/><circle cx="356" cy="70" r="12" fill="#fff" opacity=".09"/><path d="${moonPath(356, 70, 12, moonAge(a.now))}" fill="#f3efe2"/></g>`
136 : ''
137
138 const past = a.now ? samples.filter(s => s.t <= a.now!.getTime()) : []
139 const passed = a.now ? line([...past, { t: a.now.getTime(), alt: nowAlt }]) : ''
140
141 const markers = ORDER.map(name => {
142 const at = a.times[name].getTime()
143 const mx = n(x(at))
144 const my = n(y(sunAltitude(a.times[name], a.latitude, a.longitude)))
145 const lx = n(Math.max(36, Math.min(W - 36, mx)))
146 const isNext = name === a.next
147 const dot =
148 name === 'Sunrise'
149 ? `<circle class="dot" cx="${mx}" cy="${my}" r="3.2" fill="none" stroke="#fff" stroke-width="1.6"/>`
150 : `<circle class="dot" cx="${mx}" cy="${my}" r="${isNext ? 4.6 : 3.4}" fill="#fff"/>`
151 const ring = isNext
152 ? `<circle cx="${mx}" cy="${my}" r="7" fill="none" stroke="#fff" stroke-width="1.4" opacity=".7"><animate attributeName="r" values="6;10;6" dur="2.4s" repeatCount="indefinite"/><animate attributeName="opacity" values=".8;0;.8" dur="2.4s" repeatCount="indefinite"/></circle>`
153 : ''
154 return `<g class="m${isNext ? ' next' : ''}"><title>${esc(a.labels[name])} · ${esc(a.clocks[name])}</title><circle cx="${mx}" cy="${my}" r="11" fill="transparent"/>${ring}${dot}<text class="lbl" x="${lx}" y="${n(my - 11)}" text-anchor="middle">${esc(a.labels[name])} ${esc(a.clocks[name])}</text></g>`
155 }).join('')
156
157 // The sun now; below the horizon it is often below the card too, so a tick on
158 // the horizon says where in the day it is.
159 const nx = a.now ? n(x(a.now.getTime())) : 0
160 const sunMark = a.now
161 ? nowAlt > -2
162 ? `<g><circle cx="${nx}" cy="${n(y(nowAlt))}" r="24" fill="url(#sunGlow)"><animate attributeName="r" values="21;27;21" dur="4s" repeatCount="indefinite"/></circle><circle cx="${nx}" cy="${n(y(nowAlt))}" r="7.5" fill="#fff5cf"/></g>`
163 : `<g><title>Now: the sun is ${Math.round(-nowAlt)}° below the horizon</title><path d="M${nx} ${HORIZON}V${H - 20}" stroke="#fff" stroke-opacity=".45" stroke-width="1.2" stroke-dasharray="2 3"/><circle cx="${nx}" cy="${HORIZON}" r="3.6" fill="#fff"/><text x="${nx}" y="${HORIZON - 8}" text-anchor="middle" class="now">now</text>${
164 y(nowAlt) < H - 4
165 ? `<circle cx="${nx}" cy="${n(y(nowAlt))}" r="4.5" fill="${sky.top}" stroke="#fff" stroke-width="1.6" opacity=".9"/>`
166 : ''
167 }</g>`
168 : ''
169
170 const hours = [6, 12, 18]
171 .map(h => {
172 const t = new Date(a.day.getFullYear(), a.day.getMonth(), a.day.getDate(), h).getTime()
173 const text = h === 12 ? 'noon' : h < 12 ? `${h} am` : `${h - 12} pm`
174 return `<text x="${n(x(t))}" y="${H - 8}" text-anchor="middle" class="hour">${text}</text>`
175 })
176 .join('')
177
178 const bar =
179 a.progress === null
180 ? ''
181 : `<rect x="20" y="100" width="130" height="3" rx="1.5" fill="#fff" opacity=".22"/><rect x="20" y="100" width="${n(130 * Math.max(0.02, Math.min(1, a.progress)))}" height="3" rx="1.5" fill="#fff" opacity=".9"/>`
182
183 const pillWidth = a.pill ? Math.round(a.pill.length * 6.1 + 20) : 0
184 const pill = a.pill
185 ? `<g><rect x="${W - 14 - pillWidth}" y="16" width="${pillWidth}" height="22" rx="11" fill="#000" opacity=".22"/><text x="${W - 14 - pillWidth / 2}" y="31" text-anchor="middle" class="pill">${esc(a.pill)}</text></g>`
186 : ''
187
188 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMinYMin meet">
189<defs>
190<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${sky.top}"/><stop offset="${n(HORIZON / H)}" stop-color="${sky.horizon}"/><stop offset="1" stop-color="${sky.horizon}"/></linearGradient>
191<radialGradient id="sunGlow"><stop offset="0" stop-color="#fff3c4" stop-opacity=".95"/><stop offset=".45" stop-color="#ffd27a" stop-opacity=".45"/><stop offset="1" stop-color="#ffb347" stop-opacity="0"/></radialGradient>
192<radialGradient id="moonGlow"><stop offset=".5" stop-color="#f3efe2" stop-opacity=".18"/><stop offset="1" stop-color="#f3efe2" stop-opacity="0"/></radialGradient>
193<clipPath id="card"><rect width="${W}" height="${H}" rx="14"/></clipPath>
194</defs>
195<style>
196${SCHEME}
197text{font-family:${FONT};fill:#fff}
198.eyebrow{font-size:10.5px;font-weight:600;letter-spacing:1.3px;opacity:.75}
199.big{font-size:31px;font-weight:650;letter-spacing:-.4px}
200.detail{font-size:13.5px;opacity:.92}
201.pill{font-size:11px;font-weight:600}
202.hour{font-size:9px;opacity:.5}
203.now{font-size:9.5px;font-weight:600;opacity:.8}
204.lbl{font-size:10.5px;font-weight:600;opacity:0;transition:opacity .15s;paint-order:stroke;stroke:rgba(0,0,0,.35);stroke-width:3px}
205.m:hover .lbl,.m.next .lbl{opacity:1}
206.m .dot{transition:transform .15s;transform-box:fill-box;transform-origin:center}
207.m:hover .dot{transform:scale(1.7)}
208.m{cursor:default}
209</style>
210<g clip-path="url(#card)">
211<rect width="${W}" height="${H}" fill="url(#sky)"/>
212${stars}${moon}
213<rect y="${HORIZON}" width="${W}" height="${H - HORIZON}" fill="#000" opacity=".24"/>
214<line x1="0" y1="${HORIZON}" x2="${W}" y2="${HORIZON}" stroke="#fff" stroke-opacity=".35" stroke-width="1"/>
215<path d="${line(samples)}" fill="none" stroke="#fff" stroke-opacity=".35" stroke-width="1.6" stroke-dasharray="3 4"/>
216${passed ? `<path d="${passed}" fill="none" stroke="#fff" stroke-opacity=".9" stroke-width="2.2" stroke-linecap="round"/>` : ''}
217${hours}${markers}${sunMark}
218<text x="20" y="32" class="eyebrow">${esc(a.eyebrow.toUpperCase())}</text>
219<text x="19" y="65" class="big">${esc(a.heading)}</text>
220<text x="20" y="88" class="detail">${esc(a.detail)}</text>
221${bar}${pill}
222</g>
223</svg>`
224}
225
226// One small pictogram per time, in the colour of that part of the day. Asr is a
227// stick and its shadow, since Asr begins when the shadow matches the stick.
228const ICONS: Record<PrayerName, (c: string) => string> = {
229 Fajr: c =>
230 `<path d="M3 17h18" stroke="${c}" stroke-width="1.8" stroke-linecap="round"/><path d="M6 17a6 6 0 0 1 12 0" fill="none" stroke="${c}" stroke-width="1.8" stroke-dasharray="2 2.2"/><path d="M18 3.5l.7 1.6 1.6.7-1.6.7-.7 1.6-.7-1.6-1.6-.7 1.6-.7z" fill="${c}"/>`,
231 Sunrise: c =>
232 `<path d="M3 18h18" stroke="${c}" stroke-width="1.8" stroke-linecap="round"/><path d="M7 18a5 5 0 0 1 10 0z" fill="${c}"/><path d="M12 4v4M9.5 6.5L12 4l2.5 2.5M4.5 12.5l1.6 1M19.5 12.5l-1.6 1" fill="none" stroke="${c}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`,
233 Dhuhr: c =>
234 `<circle cx="12" cy="12" r="4.4" fill="${c}"/><g stroke="${c}" stroke-width="1.7" stroke-linecap="round">${[0, 45, 90, 135, 180, 225, 270, 315]
235 .map(r => `<path d="M12 3.2v2.2" transform="rotate(${r} 12 12)"/>`)
236 .join('')}</g>`,
237 Asr: c =>
238 `<circle cx="17.5" cy="6.5" r="3.4" fill="${c}"/><path d="M6.5 20V11" stroke="${c}" stroke-width="2" stroke-linecap="round"/><path d="M6.5 20h10" stroke="${c}" stroke-width="2" stroke-linecap="round" opacity=".45"/>`,
239 Maghrib: c =>
240 `<path d="M3 18h18" stroke="${c}" stroke-width="1.8" stroke-linecap="round"/><path d="M7 18a5 5 0 0 1 10 0z" fill="${c}"/><path d="M12 3.5v4.5M9.5 5.5L12 8l2.5-2.5" fill="none" stroke="${c}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`,
241 Isha: c =>
242 `<path d="M15.5 4.2a8 8 0 1 0 4.3 12.6A6.5 6.5 0 0 1 15.5 4.2z" fill="${c}"/><path d="M18.5 3.5l.6 1.4 1.4.6-1.4.6-.6 1.4-.6-1.4-1.4-.6 1.4-.6z" fill="${c}"/>`,
243}
244
245const ICON_COLOURS: Record<PrayerName, string> = {
246 Fajr: '#8f9cf7',
247 Sunrise: '#f5a54a',
248 Dhuhr: '#f2bd2c',
249 Asr: '#ea9a35',
250 Maghrib: '#ee6f4a',
251 Isha: '#9aa6ff',
252}
253
254export const icon = (name: PrayerName, dim: boolean) =>
255 `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g opacity="${dim ? 0.4 : 1}">${ICONS[name](ICON_COLOURS[name])}</g></svg>`
256
257export type WeekDay = { letter: string; count: number; isToday: boolean; title: string }
258
259const DAY_WIDTH = 44
260const WEEK_HEIGHT = 56
261
262// The week's own size, for the frame it is drawn in (as HERO_SIZE is).
263export const weekSize = (days: number) => ({ width: DAY_WIDTH * days, height: WEEK_HEIGHT })
264
265// Seven rings, one a day, each filling as that day's five prayers are marked.
266export const week = (days: WeekDay[]) => {
267 const w = DAY_WIDTH
268 const r = 13
269 const c = 2 * Math.PI * r
270 const rings = days
271 .map((d, i) => {
272 const cx = i * w + w / 2
273 const share = Math.min(1, d.count / 5)
274 const full = d.count >= 5
275 const arc =
276 share > 0
277 ? `<circle cx="${cx}" cy="20" r="${r}" fill="none" stroke="#34c27a" stroke-width="4" stroke-linecap="round" stroke-dasharray="${n(c * share)} ${n(c)}" transform="rotate(-90 ${cx} 20)"/>`
278 : ''
279 const inner = full
280 ? `<path d="M${cx - 5} 20.5l3.4 3.2 6.2-7" fill="none" stroke="#34c27a" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`
281 : d.count > 0
282 ? `<text x="${cx}" y="24" text-anchor="middle" class="count">${d.count}</text>`
283 : ''
284 return `<g class="day"><title>${esc(d.title)}</title><circle cx="${cx}" cy="20" r="${r}" fill="none" stroke="#8a8f98" stroke-opacity=".28" stroke-width="4"/>${arc}${inner}<text x="${cx}" y="50" text-anchor="middle" class="letter${d.isToday ? ' today' : ''}">${esc(d.letter)}</text></g>`
285 })
286 .join('')
287 const { width, height } = weekSize(days.length)
288 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet"><style>${SCHEME}text{font-family:${FONT};fill:#8a8f98}.count{font-size:11px;font-weight:600}.letter{font-size:10px;font-weight:600}.today{fill:#34c27a}.day{cursor:default}.day:hover circle:first-of-type{stroke-opacity:.5}</style>${rings}</svg>`
289}
290
291// A compass rose with the needle on the Qibla, the Kaaba at its tip.
292export const compass = (bearing: number) => {
293 const ticks = Array.from({ length: 12 }, (_, i) =>
294 `<path d="M28 ${i % 3 === 0 ? 5 : 6.5}V${i % 3 === 0 ? 9.5 : 8.5}" stroke="#8a8f98" stroke-opacity="${i % 3 === 0 ? 0.8 : 0.45}" stroke-width="1.4" stroke-linecap="round" transform="rotate(${i * 30} 28 28)"/>`,
295 ).join('')
296 return `<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 56 56"><title>Qibla ${Math.round(bearing)}° from north</title><circle cx="28" cy="28" r="25" fill="none" stroke="#8a8f98" stroke-opacity=".35" stroke-width="1.4"/>${ticks}<text x="28" y="19" text-anchor="middle" font-family="${FONT}" font-size="7.5" font-weight="700" fill="#8a8f98">N</text><g transform="rotate(${n(bearing)} 28 28)"><path d="M28 28L28 11" stroke="#34c27a" stroke-width="2.2" stroke-linecap="round"/><rect x="24.5" y="5.5" width="7" height="7" rx="1" fill="#1d1d1f" stroke="#d8b45a" stroke-width="1.2"/><path d="M24.5 8h7" stroke="#d8b45a" stroke-width="1"/></g><circle cx="28" cy="28" r="2.6" fill="#34c27a"/></svg>`
297}
298hooks/chime.ts 47 lines1// A soft two-note bell, synthesized as a 16-bit mono WAV: no sound file ships
2// with the plugin, so there is nothing to license.
3
4const RATE = 22050
5
6const note = (out: Float32Array, start: number, freq: number, length: number, gain: number) => {
7 for (let i = 0; i < length && start + i < out.length; i++) {
8 const t = i / RATE
9 // A bell: the fundamental plus two quiet partials, fading out.
10 const envelope = Math.exp(-3.2 * t) * Math.min(1, t * 200)
11 const sample =
12 Math.sin(2 * Math.PI * freq * t) + 0.35 * Math.sin(2 * Math.PI * freq * 2.76 * t) + 0.12 * Math.sin(2 * Math.PI * freq * 5.4 * t)
13 out[start + i] = (out[start + i] ?? 0) + sample * envelope * gain
14 }
15}
16
17export const chimeWav = (): Uint8Array => {
18 const total = Math.floor(RATE * 2.4)
19 const pcm = new Float32Array(total)
20 note(pcm, 0, 659.25, total, 0.22) // E5
21 note(pcm, Math.floor(RATE * 0.45), 987.77, total, 0.18) // B5
22 const bytes = new Uint8Array(44 + total * 2)
23 const view = new DataView(bytes.buffer)
24 const text = (at: number, s: string) => [...s].forEach((c, i) => view.setUint8(at + i, c.charCodeAt(0)))
25 text(0, 'RIFF')
26 view.setUint32(4, 36 + total * 2, true)
27 text(8, 'WAVE')
28 text(12, 'fmt ')
29 view.setUint32(16, 16, true)
30 view.setUint16(20, 1, true) // PCM
31 view.setUint16(22, 1, true) // mono
32 view.setUint32(24, RATE, true)
33 view.setUint32(28, RATE * 2, true)
34 view.setUint16(32, 2, true)
35 view.setUint16(34, 16, true)
36 text(36, 'data')
37 view.setUint32(40, total * 2, true)
38 for (let i = 0; i < total; i++) view.setInt16(44 + i * 2, Math.max(-1, Math.min(1, pcm[i] ?? 0)) * 32767, true)
39 return bytes
40}
41
42export const toBase64 = (bytes: Uint8Array) => {
43 let binary = ''
44 for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
45 return btoa(binary)
46}
47hooks/times.ts 270 lines1// SPDX-License-Identifier: LGPL-3.0-only
2//
3// Prayer time calculation, ported to TypeScript from PrayTimes.js 2.3 by Hamid
4// Zarrabi-Zadeh (praytimes.org), which is licensed under the GNU LGPL v3.0. This
5// file is under that license too (see ../LICENSES/LGPL-3.0.txt and GPL-3.0.txt);
6// the rest of this plugin is MIT. Modified from the original: rewritten in
7// TypeScript, trimmed to five methods, times rounded to the minute, and extended
8// with the next prayer, the sun's altitude, the moon's age, the Qibla and places.
9//
10// Pure: no engine calls, so it can be tested alone.
11
12export type Method = 'ISNA' | 'MWL' | 'Egyptian' | 'UmmAlQura' | 'Karachi'
13export type AsrMethod = 'Standard' | 'Hanafi'
14export type PrayerName = 'Fajr' | 'Sunrise' | 'Dhuhr' | 'Asr' | 'Maghrib' | 'Isha'
15
16// Fajr angle, and Isha as an angle or minutes after Maghrib.
17const METHODS: Record<Method, { fajr: number; isha: { angle: number } | { minutes: number } }> = {
18 ISNA: { fajr: 15, isha: { angle: 15 } },
19 MWL: { fajr: 18, isha: { angle: 17 } },
20 Egyptian: { fajr: 19.5, isha: { angle: 17.5 } },
21 UmmAlQura: { fajr: 18.5, isha: { minutes: 90 } },
22 Karachi: { fajr: 18, isha: { angle: 18 } },
23}
24
25export const METHOD_NAMES: Record<Method, string> = {
26 ISNA: 'ISNA (15°/15°)',
27 MWL: 'Muslim World League (18°/17°)',
28 Egyptian: 'Egyptian (19.5°/17.5°)',
29 UmmAlQura: 'Umm al-Qura (18.5°, Isha 90 min)',
30 Karachi: 'Karachi (18°/18°)',
31}
32
33const rad = (d: number) => (d * Math.PI) / 180
34const deg = (r: number) => (r * 180) / Math.PI
35const fix = (a: number, b: number) => {
36 const r = a - b * Math.floor(a / b)
37 return r < 0 ? r + b : r
38}
39const fixAngle = (a: number) => fix(a, 360)
40const fixHour = (a: number) => fix(a, 24)
41
42const julian = (year: number, month: number, day: number) => {
43 if (month <= 2) {
44 year -= 1
45 month += 12
46 }
47 const a = Math.floor(year / 100)
48 const b = 2 - a + Math.floor(a / 4)
49 return Math.floor(365.25 * (year + 4716)) + Math.floor(30.6001 * (month + 1)) + day + b - 1524.5
50}
51
52// Declination and equation of time for a Julian date.
53const sun = (jd: number) => {
54 const d = jd - 2451545.0
55 const g = fixAngle(357.529 + 0.98560028 * d)
56 const q = fixAngle(280.459 + 0.98564736 * d)
57 const l = fixAngle(q + 1.915 * Math.sin(rad(g)) + 0.02 * Math.sin(rad(2 * g)))
58 const e = 23.439 - 0.00000036 * d
59 const ra = deg(Math.atan2(Math.cos(rad(e)) * Math.sin(rad(l)), Math.cos(rad(l)))) / 15
60 return { declination: deg(Math.asin(Math.sin(rad(e)) * Math.sin(rad(l)))), equation: q / 15 - fixHour(ra) }
61}
62
63export type Settings = { latitude: number; longitude: number; method: Method; asr: AsrMethod }
64
65// The day's times as Dates, for the local calendar day of `day`.
66export const prayerTimes = (day: Date, s: Settings): Record<PrayerName, Date> => {
67 const y = day.getFullYear()
68 const m = day.getMonth() + 1
69 const d = day.getDate()
70 const zone = -new Date(y, m - 1, d, 12).getTimezoneOffset() / 60
71 const jd = julian(y, m, d) - s.longitude / (15 * 24)
72
73 const midDay = (t: number) => fixHour(12 - sun(jd + t).equation)
74 const angleTime = (angle: number, t: number, before: boolean) => {
75 const { declination } = sun(jd + t)
76 const noon = midDay(t)
77 const cosH =
78 (-Math.sin(rad(angle)) - Math.sin(rad(declination)) * Math.sin(rad(s.latitude))) /
79 (Math.cos(rad(declination)) * Math.cos(rad(s.latitude)))
80 const h = deg(Math.acos(Math.max(-1, Math.min(1, cosH)))) / 15
81 return noon + (before ? -h : h)
82 }
83 const asrTime = (factor: number, t: number) => {
84 const { declination } = sun(jd + t)
85 const angle = -deg(Math.atan(1 / (factor + Math.tan(rad(Math.abs(s.latitude - declination))))))
86 return angleTime(angle, t, false)
87 }
88
89 const method = METHODS[s.method]
90 // Each pass evaluates the sun at the previous pass's times; three passes settle
91 // to well under a second (one, from rough guesses, can leave Asr a minute out).
92 const p = (hour: number) => hour / 24
93 let hours = { Fajr: 5, Sunrise: 6, Dhuhr: 12, Asr: 13, Maghrib: 18, Isha: 18 }
94 for (let pass = 0; pass < 3; pass++) {
95 hours = {
96 Fajr: angleTime(method.fajr, p(hours.Fajr), true),
97 Sunrise: angleTime(0.833, p(hours.Sunrise), true),
98 Dhuhr: midDay(p(hours.Dhuhr)),
99 Asr: asrTime(s.asr === 'Hanafi' ? 2 : 1, p(hours.Asr)),
100 Maghrib: angleTime(0.833, p(hours.Maghrib), false),
101 Isha: 'angle' in method.isha ? angleTime(method.isha.angle, p(hours.Isha), false) : hours.Isha,
102 }
103 }
104 if ('minutes' in method.isha) hours.Isha = hours.Maghrib + method.isha.minutes / 60
105
106 const out = {} as Record<PrayerName, Date>
107 for (const [name, hour] of Object.entries(hours) as Array<[PrayerName, number]>) {
108 const local = hour + zone - s.longitude / 15
109 // Nearest minute, as published timetables (aladhan.com among them) round.
110 const minutes = Math.round(local * 60)
111 out[name] = new Date(y, m - 1, d, 0, minutes)
112 }
113 return out
114}
115
116export const ORDER: PrayerName[] = ['Fajr', 'Sunrise', 'Dhuhr', 'Asr', 'Maghrib', 'Isha']
117// Sunrise is shown, but it is the end of Fajr, not a prayer to announce.
118export const PRAYERS: PrayerName[] = ['Fajr', 'Dhuhr', 'Asr', 'Maghrib', 'Isha']
119
120// The next prayer after `now`: today's, or tomorrow's Fajr.
121export const nextPrayer = (now: Date, s: Settings) => {
122 const today = prayerTimes(now, s)
123 for (const name of PRAYERS) if (today[name] > now) return { name, at: today[name] }
124 const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
125 return { name: 'Fajr' as PrayerName, at: prayerTimes(tomorrow, s).Fajr }
126}
127
128// The prayer whose time began within `windowMs` before `now`, if any.
129export const currentPrayer = (now: Date, s: Settings, windowMs: number) => {
130 const today = prayerTimes(now, s)
131 for (const name of [...PRAYERS].reverse()) {
132 const at = today[name]
133 if (at <= now && now.getTime() - at.getTime() < windowMs) return { name, at }
134 }
135 return null
136}
137
138// Friday's Dhuhr is Jumu'ah.
139export const label = (name: PrayerName, at: Date) => (name === 'Dhuhr' && at.getDay() === 5 ? "Jumu'ah" : name)
140
141// A sensible starting point from the time zone alone, for anyone who has not set
142// a location: the zone's main city, and the method most used there.
143const ZONES: Record<string, { city: string; latitude: number; longitude: number; method: Method }> = {
144 'America/New_York': { city: 'New York', latitude: 40.71, longitude: -74.01, method: 'ISNA' },
145 'America/Detroit': { city: 'Detroit', latitude: 42.33, longitude: -83.05, method: 'ISNA' },
146 'America/Toronto': { city: 'Toronto', latitude: 43.65, longitude: -79.38, method: 'ISNA' },
147 'America/Chicago': { city: 'Chicago', latitude: 41.88, longitude: -87.63, method: 'ISNA' },
148 'America/Denver': { city: 'Denver', latitude: 39.74, longitude: -104.99, method: 'ISNA' },
149 'America/Phoenix': { city: 'Phoenix', latitude: 33.45, longitude: -112.07, method: 'ISNA' },
150 'America/Los_Angeles': { city: 'Los Angeles', latitude: 34.05, longitude: -118.24, method: 'ISNA' },
151 'America/Vancouver': { city: 'Vancouver', latitude: 49.28, longitude: -123.12, method: 'ISNA' },
152 'America/Mexico_City': { city: 'Mexico City', latitude: 19.43, longitude: -99.13, method: 'MWL' },
153 'America/Sao_Paulo': { city: 'São Paulo', latitude: -23.55, longitude: -46.63, method: 'MWL' },
154 'Europe/London': { city: 'London', latitude: 51.51, longitude: -0.13, method: 'MWL' },
155 'Europe/Dublin': { city: 'Dublin', latitude: 53.35, longitude: -6.26, method: 'MWL' },
156 'Europe/Paris': { city: 'Paris', latitude: 48.86, longitude: 2.35, method: 'MWL' },
157 'Europe/Berlin': { city: 'Berlin', latitude: 52.52, longitude: 13.41, method: 'MWL' },
158 'Europe/Amsterdam': { city: 'Amsterdam', latitude: 52.37, longitude: 4.9, method: 'MWL' },
159 'Europe/Brussels': { city: 'Brussels', latitude: 50.85, longitude: 4.35, method: 'MWL' },
160 'Europe/Madrid': { city: 'Madrid', latitude: 40.42, longitude: -3.7, method: 'MWL' },
161 'Europe/Rome': { city: 'Rome', latitude: 41.9, longitude: 12.5, method: 'MWL' },
162 'Europe/Stockholm': { city: 'Stockholm', latitude: 59.33, longitude: 18.07, method: 'MWL' },
163 'Europe/Istanbul': { city: 'Istanbul', latitude: 41.01, longitude: 28.98, method: 'MWL' },
164 'Africa/Cairo': { city: 'Cairo', latitude: 30.04, longitude: 31.24, method: 'Egyptian' },
165 'Africa/Casablanca': { city: 'Casablanca', latitude: 33.57, longitude: -7.59, method: 'MWL' },
166 'Africa/Lagos': { city: 'Lagos', latitude: 6.52, longitude: 3.38, method: 'MWL' },
167 'Africa/Nairobi': { city: 'Nairobi', latitude: -1.29, longitude: 36.82, method: 'MWL' },
168 'Africa/Johannesburg': { city: 'Johannesburg', latitude: -26.2, longitude: 28.05, method: 'MWL' },
169 'Asia/Riyadh': { city: 'Riyadh', latitude: 24.71, longitude: 46.68, method: 'UmmAlQura' },
170 'Asia/Dubai': { city: 'Dubai', latitude: 25.2, longitude: 55.27, method: 'UmmAlQura' },
171 'Asia/Qatar': { city: 'Doha', latitude: 25.29, longitude: 51.53, method: 'UmmAlQura' },
172 'Asia/Kuwait': { city: 'Kuwait City', latitude: 29.38, longitude: 47.99, method: 'UmmAlQura' },
173 'Asia/Amman': { city: 'Amman', latitude: 31.95, longitude: 35.93, method: 'MWL' },
174 'Asia/Beirut': { city: 'Beirut', latitude: 33.89, longitude: 35.5, method: 'MWL' },
175 'Asia/Baghdad': { city: 'Baghdad', latitude: 33.31, longitude: 44.36, method: 'MWL' },
176 'Asia/Karachi': { city: 'Karachi', latitude: 24.86, longitude: 67.0, method: 'Karachi' },
177 'Asia/Kolkata': { city: 'Delhi', latitude: 28.61, longitude: 77.21, method: 'Karachi' },
178 'Asia/Dhaka': { city: 'Dhaka', latitude: 23.81, longitude: 90.41, method: 'Karachi' },
179 'Asia/Jakarta': { city: 'Jakarta', latitude: -6.21, longitude: 106.85, method: 'MWL' },
180 'Asia/Kuala_Lumpur': { city: 'Kuala Lumpur', latitude: 3.14, longitude: 101.69, method: 'MWL' },
181 'Asia/Singapore': { city: 'Singapore', latitude: 1.35, longitude: 103.82, method: 'MWL' },
182 'Australia/Sydney': { city: 'Sydney', latitude: -33.87, longitude: 151.21, method: 'MWL' },
183 'Australia/Melbourne': { city: 'Melbourne', latitude: -37.81, longitude: 144.96, method: 'MWL' },
184}
185
186export const guessFromZone = (zone: string) => ZONES[zone] ?? { city: 'Mecca', latitude: 21.42, longitude: 39.83, method: 'UmmAlQura' as Method }
187
188export const clock = (at: Date) => at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
189
190// The sun's altitude above the horizon at `at`, in degrees: what the pane's sky
191// is drawn from.
192export const sunAltitude = (at: Date, latitude: number, longitude: number) => {
193 const { declination, equation } = sun(at.getTime() / 86400000 + 2440587.5)
194 const utc = at.getUTCHours() + at.getUTCMinutes() / 60 + at.getUTCSeconds() / 3600
195 const solar = utc + longitude / 15 + (fix(equation + 12, 24) - 12)
196 const hourAngle = (solar - 12) * 15
197 const sinAlt =
198 Math.sin(rad(latitude)) * Math.sin(rad(declination)) +
199 Math.cos(rad(latitude)) * Math.cos(rad(declination)) * Math.cos(rad(hourAngle))
200 return deg(Math.asin(Math.max(-1, Math.min(1, sinAlt))))
201}
202
203// Days since the last new moon, from a known one (2000-01-06 18:14 UTC); good to
204// within a day, which is all a drawing of the moon needs.
205export const moonAge = (at: Date) => fix(at.getTime() / 86400000 + 2440587.5 - 2451550.26, 29.530588853)
206
207const KAABA = { latitude: 21.4225, longitude: 39.8262 }
208
209// Bearing to the Kaaba from true north, clockwise, and the distance there.
210export const qibla = (latitude: number, longitude: number) => {
211 const φ = rad(latitude)
212 const φk = rad(KAABA.latitude)
213 const Δλ = rad(KAABA.longitude - longitude)
214 const bearing = fixAngle(deg(Math.atan2(Math.sin(Δλ), Math.cos(φ) * Math.tan(φk) - Math.sin(φ) * Math.cos(Δλ))))
215 const a = Math.sin((φk - φ) / 2) ** 2 + Math.cos(φ) * Math.cos(φk) * Math.sin(Δλ / 2) ** 2
216 return { bearing, km: 2 * 6371 * Math.asin(Math.sqrt(a)) }
217}
218
219export const compassPoint = (bearing: number) =>
220 ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.round(fixAngle(bearing) / 45) % 8]
221
222// Places the location field knows by name, beyond the time zones' cities.
223const PLACES: Record<string, { latitude: number; longitude: number }> = {
224 ...Object.fromEntries(Object.values(ZONES).map(z => [z.city, { latitude: z.latitude, longitude: z.longitude }])),
225 'Washington DC': { latitude: 38.91, longitude: -77.04 },
226 'Northern Virginia': { latitude: 38.85, longitude: -77.31 },
227 Baltimore: { latitude: 39.29, longitude: -76.61 },
228 Philadelphia: { latitude: 39.95, longitude: -75.17 },
229 Boston: { latitude: 42.36, longitude: -71.06 },
230 Atlanta: { latitude: 33.75, longitude: -84.39 },
231 Houston: { latitude: 29.76, longitude: -95.37 },
232 Dallas: { latitude: 32.78, longitude: -96.8 },
233 Minneapolis: { latitude: 44.98, longitude: -93.27 },
234 'San Francisco': { latitude: 37.77, longitude: -122.42 },
235 Seattle: { latitude: 47.61, longitude: -122.33 },
236 Montreal: { latitude: 45.5, longitude: -73.57 },
237 Birmingham: { latitude: 52.49, longitude: -1.89 },
238 Manchester: { latitude: 53.48, longitude: -2.24 },
239 Makkah: { latitude: 21.42, longitude: 39.83 },
240 Medina: { latitude: 24.47, longitude: 39.61 },
241 Jeddah: { latitude: 21.49, longitude: 39.19 },
242 Jerusalem: { latitude: 31.78, longitude: 35.22 },
243 Alexandria: { latitude: 31.2, longitude: 29.92 },
244 Khartoum: { latitude: 15.5, longitude: 32.56 },
245 Lahore: { latitude: 31.55, longitude: 74.34 },
246 Islamabad: { latitude: 33.68, longitude: 73.05 },
247 'Abu Dhabi': { latitude: 24.45, longitude: 54.38 },
248}
249
250// What the location field accepts: "38.85, -77.31", "38.85, -77.31 Fairfax", or
251// a place it knows by name. Null when it cannot tell.
252export const parseLocation = (text: string) => {
253 const input = text.trim()
254 const coords = input.match(/^(-?\d+(?:\.\d+)?)\s*[,\s]\s*(-?\d+(?:\.\d+)?)\s*,?\s*(.*)$/)
255 if (coords) {
256 const latitude = Number(coords[1])
257 const longitude = Number(coords[2])
258 if (Math.abs(latitude) > 90 || Math.abs(longitude) > 180) return null
259 return { latitude, longitude, place: coords[3]?.trim() || `${latitude.toFixed(2)}, ${longitude.toFixed(2)}` }
260 }
261 const wanted = input.toLowerCase()
262 if (!wanted) return null
263 const names = Object.keys(PLACES)
264 const name =
265 names.find(n => n.toLowerCase() === wanted) ??
266 names.find(n => n.toLowerCase().startsWith(wanted)) ??
267 names.find(n => n.toLowerCase().includes(wanted))
268 return name ? { ...PLACES[name]!, place: name } : null
269}
270types/index.d.ts 36 lines1export type PrayerTimesTick = number
2
3// What the pane's own settings hold; each one set there wins over /config.
4export type PrayerTimesPrefs = {
5 latitude?: number
6 longitude?: number
7 place?: string
8 method?: string
9 asr?: string
10 sound?: string
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'prayer-times': {
16 // The text this mod adds to the prompt footer's labels.
17 footer: string | null
18 // The current minute, rewritten when it changes so drawings that show a
19 // countdown redraw.
20 minute: PrayerTimesTick
21 // Prayers whose reminder row was dismissed, as "YYYY-MM-DD:Name".
22 done: string[]
23 // Which day the pane shows, in days from today.
24 day: number
25 // Prayers marked as prayed, by "YYYY-MM-DD"; the store keeps the record.
26 prayed: Record<string, string[]>
27 // The pane's own settings; the store keeps the record.
28 prefs: PrayerTimesPrefs
29 // Whether the pane's settings are open.
30 editing: boolean
31 // The line under the location field when it could not place what was typed.
32 notice: string
33 }
34 }
35}
36