diff --git a/CHANGELOG.md b/CHANGELOG.md index b929b38..909352a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## [2.1.0] - 2026-03-22 + +### Added + +- `getMidnight()` function: computes the midpoint of the night (Maghrib to Fajr). Useful as the Isha prayer endpoint per the hadith in Sahih Muslim. Also works with sunrise as the second anchor for the astronomical variant. +- `Midnight` field added to `PrayerTimes`, `FormattedPrayerTimes`, `PrayerTimesAll`, and `FormattedPrayerTimesAll` interfaces +- 15 new tests covering `getMidnight` standalone and integrated output + ## [2.0.0] - 2026-02-25 ### Added diff --git a/README.md b/README.md index 368398a..81ef439 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Returns raw fractional-hour prayer times using the dynamic method. | `pressure` | `number` | `1013.25` | Atmospheric pressure, mbar | | `hanafi` | `boolean` | `false` | Asr convention: false = Shafi'i, true = Hanafi | -Returns `PrayerTimes`: `{ Qiyam, Fajr, Sunrise, Noon, Dhuhr, Asr, Maghrib, Isha, angles }`. +Returns `PrayerTimes`: `{ Qiyam, Fajr, Sunrise, Noon, Dhuhr, Asr, Maghrib, Isha, Midnight, angles }`. All times are fractional hours in local time (e.g., `5.5` = 05:30:00). `NaN` when an event cannot be computed (polar night, etc.). @@ -101,6 +101,11 @@ Computes Asr from solar noon time, latitude, and solar declination. Returns frac Returns the start of the last third of the night as fractional hours. +### `getMidnight(maghribTime, endTime)` + +Returns the midpoint of the night as fractional hours. Pass Fajr as `endTime` for the +standard definition (Maghrib-to-Fajr midpoint), or Sunrise for the astronomical variant. + ### `getMscFajr(date, latitude)` / `getMscIsha(date, latitude, shafaq?)` Moonsighting Committee Worldwide minute offsets: minutes before sunrise (Fajr) and diff --git a/package.json b/package.json index b850cf9..184876a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pray-calc", - "version": "2.0.0", + "version": "2.1.0", "description": "Islamic prayer times with a physics-grounded dynamic twilight angle algorithm. Covers Fajr, Sunrise, Dhuhr, Asr, Maghrib, Isha, Qiyam. Includes 14 traditional fixed-angle methods for comparison.", "author": "Aric Camarata", "license": "MIT", diff --git a/src/calcTimes.ts b/src/calcTimes.ts index c220370..ab025ce 100644 --- a/src/calcTimes.ts +++ b/src/calcTimes.ts @@ -38,6 +38,7 @@ export function calcTimes( Asr: formatTime(raw.Asr), Maghrib: formatTime(raw.Maghrib), Isha: formatTime(raw.Isha), + Midnight: formatTime(raw.Midnight), angles: raw.angles, }; } diff --git a/src/calcTimesAll.ts b/src/calcTimesAll.ts index 9b3e91d..9e154c8 100644 --- a/src/calcTimesAll.ts +++ b/src/calcTimesAll.ts @@ -42,6 +42,7 @@ export function calcTimesAll( Asr: formatTime(raw.Asr), Maghrib: formatTime(raw.Maghrib), Isha: formatTime(raw.Isha), + Midnight: formatTime(raw.Midnight), angles: raw.angles, Methods, }; diff --git a/src/getMidnight.ts b/src/getMidnight.ts new file mode 100644 index 0000000..fd987f4 --- /dev/null +++ b/src/getMidnight.ts @@ -0,0 +1,24 @@ +/** + * Islamic midnight calculation. + * + * Returns the midpoint of the night, commonly used as the endpoint + * of the Isha prayer window. The standard definition uses the interval + * from Maghrib to Fajr; the astronomical variant uses Maghrib to Sunrise. + */ + +/** + * Compute the midpoint of the night. + * + * @param maghribTime - Maghrib (sunset) time in fractional hours + * @param endTime - Fajr or Sunrise time in fractional hours (next day) + * @returns Midnight as fractional hours + */ +export function getMidnight(maghribTime: number, endTime: number): number { + // If endTime is numerically earlier (e.g. 5.5) than Maghrib (e.g. 20.0), + // the endpoint is on the NEXT day: add 24 to get the correct span. + const adjusted = endTime < maghribTime ? endTime + 24 : endTime; + + const mid = maghribTime + (adjusted - maghribTime) / 2; + + return mid >= 24 ? mid - 24 : mid; +} diff --git a/src/getTimes.ts b/src/getTimes.ts index cb6ebc6..3ac5635 100644 --- a/src/getTimes.ts +++ b/src/getTimes.ts @@ -10,6 +10,7 @@ import { getSpa } from 'nrel-spa'; import { computeAngles } from './getAngles.js'; import { getAsr } from './getAsr.js'; import { getQiyam } from './getQiyam.js'; +import { getMidnight } from './getMidnight.js'; import { validateInputs } from './validate.js'; import { DHUHR_OFFSET_MINUTES } from './constants.js'; import type { PrayerTimes } from './types.js'; @@ -81,6 +82,9 @@ export function getTimes( // 5. Qiyam al-Layl (last third of the night). const qiyamTime = getQiyam(fajrTime, ishaTime); + // 6. Midnight: midpoint between Maghrib and Fajr (standard definition). + const midnightTime = getMidnight(maghribTime, fajrTime); + return { Qiyam: isFinite(qiyamTime) ? qiyamTime : NaN, Fajr: isFinite(fajrTime) ? fajrTime : NaN, @@ -90,6 +94,7 @@ export function getTimes( Asr: isFinite(asrTime) ? asrTime : NaN, Maghrib: isFinite(maghribTime) ? maghribTime : NaN, Isha: isFinite(ishaTime) ? ishaTime : NaN, + Midnight: isFinite(midnightTime) ? midnightTime : NaN, angles: { fajrAngle, ishaAngle }, }; } diff --git a/src/getTimesAll.ts b/src/getTimesAll.ts index 04bc378..e437cea 100644 --- a/src/getTimesAll.ts +++ b/src/getTimesAll.ts @@ -28,6 +28,7 @@ import { getSpa } from 'nrel-spa'; import { computeAngles } from './getAngles.js'; import { getAsr } from './getAsr.js'; import { getQiyam } from './getQiyam.js'; +import { getMidnight } from './getMidnight.js'; import { getMscFajr, getMscIsha } from './getMSC.js'; import { validateInputs } from './validate.js'; import { DHUHR_OFFSET_MINUTES } from './constants.js'; @@ -201,6 +202,7 @@ export function getTimesAll( // 4. Asr time (reuses declination from computeAngles — no extra ephemeris call). const asrTime = getAsr(noonTime, lat, decl, hanafi); const qiyamTime = getQiyam(fajrTime, ishaTime); + const midnightTime = getMidnight(maghribTime, fajrTime); // 5. Build Methods map. const Methods: Record = {}; @@ -237,6 +239,7 @@ export function getTimesAll( Asr: isFinite(asrTime) ? asrTime : NaN, Maghrib: isFinite(maghribTime) ? maghribTime : NaN, Isha: isFinite(ishaTime) ? ishaTime : NaN, + Midnight: isFinite(midnightTime) ? midnightTime : NaN, Methods, angles: { fajrAngle, ishaAngle }, }; diff --git a/src/index.ts b/src/index.ts index 9a7140e..327fec5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ * getAngles - Dynamic Fajr/Isha twilight depression angles * getAsr - Asr prayer time from solar noon and declination * getQiyam - Start of the last third of the night + * getMidnight - Midpoint of the night (Isha endpoint) * getMscFajr - MSC Fajr offset in minutes before sunrise * getMscIsha - MSC Isha offset in minutes after sunset * solarEphemeris - Jean Meeus solar ephemeris (declination, Earth-Sun distance, ecliptic lon) @@ -22,6 +23,7 @@ export { calcTimesAll } from './calcTimesAll.js'; export { getAngles } from './getAngles.js'; export { getAsr } from './getAsr.js'; export { getQiyam } from './getQiyam.js'; +export { getMidnight } from './getMidnight.js'; export { getMscFajr, getMscIsha } from './getMSC.js'; export { solarEphemeris, toJulianDate } from './getSolarEphemeris.js'; export { DHUHR_OFFSET_MINUTES, ANGLE_MIN, ANGLE_MAX } from './constants.js'; diff --git a/src/types.ts b/src/types.ts index e54006e..c10b49e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,6 +40,8 @@ export interface PrayerTimes { Maghrib: FractionalHours; /** Isha (nightfall, end of shafaq). */ Isha: FractionalHours; + /** Midnight: midpoint between Maghrib and Fajr. */ + Midnight: FractionalHours; /** Dynamic twilight angles used for this calculation. */ angles: TwilightAngles; } @@ -54,6 +56,7 @@ export interface FormattedPrayerTimes { Asr: TimeString; Maghrib: TimeString; Isha: TimeString; + Midnight: TimeString; angles: TwilightAngles; } @@ -84,6 +87,7 @@ export interface FormattedPrayerTimesAll { Asr: TimeString; Maghrib: TimeString; Isha: TimeString; + Midnight: TimeString; angles: TwilightAngles; /** Formatted comparison times for each method: [fajrString, ishaString]. */ Methods: Record; diff --git a/test-cjs.cjs b/test-cjs.cjs index a4fd04b..9286f7b 100644 --- a/test-cjs.cjs +++ b/test-cjs.cjs @@ -16,6 +16,7 @@ const { getAngles, getAsr, getQiyam, + getMidnight, getMscFajr, getMscIsha, solarEphemeris, @@ -75,6 +76,17 @@ describe('[CJS] Core exports', () => { assert(typeof q === 'number'); }); + it('getMidnight returns a number', () => { + const m = getMidnight(20.0, 4.0); + assert(typeof m === 'number'); + assert(Math.abs(m) < 0.001, `Expected ~0, got ${m}`); + }); + + it('getTimes includes Midnight field', () => { + const t = getTimes(new Date('2024-06-21'), 40.7128, -74.0060, -4); + assert(isFinite(t.Midnight), `Midnight=${t.Midnight}`); + }); + it('getMscFajr returns positive minutes', () => { const m = getMscFajr(new Date('2024-06-21'), 40.7); assert(m > 0); diff --git a/test.mjs b/test.mjs index 2481ef3..a6d8d8d 100644 --- a/test.mjs +++ b/test.mjs @@ -22,6 +22,7 @@ import { getAngles, getAsr, getQiyam, + getMidnight, getMscFajr, getMscIsha, solarEphemeris, @@ -274,13 +275,110 @@ describe('MSC minute offsets', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// Section 6b: getMidnight — midpoint of night +// ───────────────────────────────────────────────────────────────────────────── +describe('getMidnight', () => { + it('midpoint of 20:00–04:00 is 00:00', () => { + const m = getMidnight(20.0, 4.0); + assert(approx(m, 0.0, 0.001), `Got ${m}`); + }); + + it('midpoint of 18:00–06:00 is 00:00', () => { + const m = getMidnight(18.0, 6.0); + assert(approx(m, 0.0, 0.001), `Got ${m}`); + }); + + it('midpoint of 19:00–05:00 is 00:00', () => { + const m = getMidnight(19.0, 5.0); + assert(approx(m, 0.0, 0.001), `Got ${m}`); + }); + + it('midpoint of 21:00–03:00 is 00:00', () => { + const m = getMidnight(21.0, 3.0); + assert(approx(m, 0.0, 0.001), `Got ${m}`); + }); + + it('asymmetric night: 20:30–04:30 gives 00:30', () => { + const m = getMidnight(20.5, 4.5); + assert(approx(m, 0.5, 0.001), `Got ${m}`); + }); + + it('late sunset: 21:30–03:30 gives 00:30', () => { + const m = getMidnight(21.5, 3.5); + assert(approx(m, 0.5, 0.001), `Got ${m}`); + }); + + it('short night: 17:00–07:00 gives 00:00', () => { + const m = getMidnight(17.0, 7.0); + assert(approx(m, 0.0, 0.001), `Got ${m}`); + }); + + it('result is always between 0 and 24', () => { + for (const [mag, faj] of [[18, 6], [20, 4], [21.5, 3.5], [17, 7], [22, 2]]) { + const m = getMidnight(mag, faj); + assert(m >= 0 && m < 24, `Got ${m} for [${mag}, ${faj}]`); + } + }); +}); + +describe('Midnight in getTimes', () => { + it('Midnight field present and finite', () => { + const t = getTimes(new Date('2024-06-21'), 40.7128, -74.0060, -4); + assert('Midnight' in t, 'Missing Midnight field'); + assert(isFinite(t.Midnight), `Midnight=${t.Midnight}`); + }); + + it('Midnight falls between Isha and Fajr (next day)', () => { + const t = getTimes(new Date('2024-06-21'), 40.7128, -74.0060, -4); + // Midnight should be after Isha (both are evening/night times) + assert(t.Midnight > t.Isha || t.Midnight < t.Fajr, + `Midnight(${t.Midnight}) should be after Isha(${t.Isha}) or before Fajr(${t.Fajr})`); + }); + + it('Midnight is between Maghrib and Qiyam', () => { + const t = getTimes(new Date('2024-06-21'), 40.7128, -74.0060, -4); + if (isFinite(t.Qiyam) && isFinite(t.Midnight)) { + // Midnight (1/2 night) comes before Qiyam (2/3 night) + // Both wrap around midnight, so compare by adjusting + const adjMid = t.Midnight < t.Maghrib ? t.Midnight + 24 : t.Midnight; + const adjQiy = t.Qiyam < t.Maghrib ? t.Qiyam + 24 : t.Qiyam; + assert(adjMid < adjQiy, + `Midnight(${t.Midnight}) should come before Qiyam(${t.Qiyam})`); + } + }); + + it('Midnight around 23:00-01:00 for typical mid-latitude', () => { + const t = getTimes(new Date('2024-06-21'), 40.7128, -74.0060, -4); + // Normalize to [0,24) range for comparison + const m = t.Midnight; + assert((m >= 23 && m < 24) || (m >= 0 && m < 2), + `Expected midnight near 00:00, got ${m}`); + }); + + it('Midnight in calcTimes is HH:MM:SS', () => { + const t = calcTimes(new Date('2024-06-21'), 40.7128, -74.0060, -4); + assert(/^\d{2}:\d{2}:\d{2}$/.test(t.Midnight), `Midnight="${t.Midnight}"`); + }); + + it('Midnight in getTimesAll is present', () => { + const t = getTimesAll(new Date('2024-06-21'), 40.7128, -74.0060, -4); + assert(isFinite(t.Midnight), `Midnight=${t.Midnight}`); + }); + + it('Midnight in calcTimesAll is HH:MM:SS', () => { + const t = calcTimesAll(new Date('2024-06-21'), 40.7128, -74.0060, -4); + assert(/^\d{2}:\d{2}:\d{2}$/.test(t.Midnight), `Midnight="${t.Midnight}"`); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // Section 7: getTimes — core output structure // ───────────────────────────────────────────────────────────────────────────── describe('getTimes — structure', () => { it('returns all required fields', () => { const t = getTimes(new Date('2024-06-21'), 40.7, -74.0); - for (const field of ['Qiyam','Fajr','Sunrise','Noon','Dhuhr','Asr','Maghrib','Isha']) { + for (const field of ['Qiyam','Fajr','Sunrise','Noon','Dhuhr','Asr','Maghrib','Isha','Midnight']) { assert(field in t, `Missing field: ${field}`); } assert('angles' in t);