SLOPSHOPPER

prayer-times

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…

newpanebandspinnercommandtoast
v1.1.2MIT AND LGPL-3.0-onlyupdated 2026-10-04mkbuilds4/mods/plugins/prayer-times
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prayer-times
│ ┃ Prayer times ✕ › fix the failing auth test and add an audit log call │ ┃ Monday, October 5 ‹ › │ ┃ Rabiʻ II 24, 1448 AH ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Fajr in 7h 14m ⏺ Update(src/auth.ts) │ ┃ 5:43 AM · Isha time ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Fajr 5:42 AM ○ ⎿ 3 pass, 1 fail │ ┃ Sunrise 6:51 AM │ ┃ Dhuhr 12:41 PM ○ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Asr 4:01 PM ○ │ ┃ Maghrib 6:32 PM ○ ✻ Worked for 42s · done 4:20 PM │ ┃ • Isha 7:40 PM ○ │ ┃ › /prayer │ ┃ This week T0 W0 T0 F0 S0 S0 M0 ⎿ prayer-times: Prayer times opened. │ ┃ │ ┃ Qibla 24° NE · 13,424 km to Makkah │ ┃ Los Angeles (from your time zone) · ISNA │ ┃ (15°/15°) · Asr Standard │ ┃ │ ┃ Location & settings │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prayer times
Monday, October 5 ‹ › Rabiʻ II 24, 1448 AH Fajr in 7h 14m 5:43 AM · Isha time Fajr 5:42 AM ○ Sunrise 6:51 AM Dhuhr 12:41 PM ○ Asr 4:01 PM ○ Maghrib 6:32 PM ○ • Isha 7:40 PM ○ This week T0 W0 T0 F0 S0 S0 M0 Qibla 24° NE · 13,424 km to Makkah Los Angeles (from your time zone) · ISNA (15°/15°) · Asr Standard Location & settings
README

Prayer times for Claude Code

A small Claude Code mod that keeps the next prayer in view while you work.

  • Under the prompt: the next prayer, always, among the footer's labels. Asr 4:18 PM, or Asr in 18 min within the hour.
  • Above the prompt: a quiet reminder from 15 minutes before adhan, then Asr now for 20 minutes after, with a Done button that also marks the prayer prayed. On Fridays Dhuhr is shown as Jumu'ah.
  • At adhan: a notice and a soft chime (or a spoken reminder, or nothing). With several chats open, only one plays it.
  • /prayer: a pane with the day at a glance:
  • Today's sky. The sun's real path across the day, coloured by where it is now (night, dawn, day, sunset), with each prayer marked where the sun stands at its time. The sun glows where it is; at night there are stars and the moon in its actual phase. Hover a prayer to see its time. The next prayer, its countdown and whose time it is now sit on top.
  • The timetable, each prayer with its own icon, and a ○ beside it to mark it prayed. Seven rings fill through the week as you do. What you mark stays on your machine.
  • Other days, with the ‹ and › arrows, and the Hijri date for each.
  • The Qibla, as a compass bearing from true north and the distance to Makkah. Macs have no compass sensor, so the needle shows the bearing rather than turning as you turn; line it up with north from your phone's compass or a known landmark.
  • Location & settings, for changing them from the pane, which is the only way in the desktop app, where /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.

Install

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.

Set your location

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:

SettingWhat it doesDefault
latitude, longitudeWhere to calculate forguessed from your time zone
placeThe name shown in /prayerthe guessed city
methodISNA, MWL (Muslim World League), Egyptian, UmmAlQura, Karachi, or autoauto (by region)
asrStandard (Shafi'i, Maliki, Hanbali) or HanafiStandard
soundchime, voice, or offchime

How it calculates

It uses the standard astronomical method from PrayTimes.org:

  • Dhuhr: solar noon, from the sun's declination and the equation of time.
  • Fajr and Isha: when the sun sits the method's angle below the horizon.
  • Sunrise and Maghrib: the sun at 0.833° below the horizon, to allow for refraction.
  • Asr: when a shadow reaches the object's length (twice that for Hanafi), plus its noon shadow.

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.

Updating

claude plugin marketplace update mkbuilds
claude plugin update prayer-times@mkbuilds

Then start a new chat.

Files

  • 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).

Changes

  • 1.1.2: README screenshot and update steps, and the calculation's LGPL license stated properly.
  • 1.1.1: No white boxes behind the pane's sky and rings in dark mode in the desktop app.
  • 1.1.0: The /prayer pane draws the day's sky, marks prayers prayed with a weekly tracker, and sets your place by city name or coordinates.
  • 1.0.0: The next prayer under the prompt, the reminder, the adhan chime and the timetable.

License

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.

Source 5 files
hooks/register.tsx 620 lines
1import { 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}
620
hooks/art.ts 298 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
298
hooks/chime.ts 47 lines
1// 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}
47
hooks/times.ts 270 lines
1// 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}
270
types/index.d.ts 36 lines
1export 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