feat: add getMidnight and Midnight field to prayer times output

Computes the midpoint of the night (Maghrib to Fajr), commonly used as
the Isha prayer endpoint. Also works with sunrise as the second anchor
for the astronomical variant.

Closes #1
This commit is contained in:
Aric Camarata 2026-03-22 09:22:59 -04:00
parent 43d6241ac2
commit 4534b41957
12 changed files with 166 additions and 3 deletions

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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,
};
}

View file

@ -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,
};

24
src/getMidnight.ts Normal file
View file

@ -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;
}

View file

@ -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 },
};
}

View file

@ -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<string, [number, number]> = {};
@ -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 },
};

View file

@ -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';

View file

@ -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<string, [TimeString, TimeString]>;

View file

@ -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);

100
test.mjs
View file

@ -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:0004:00 is 00:00', () => {
const m = getMidnight(20.0, 4.0);
assert(approx(m, 0.0, 0.001), `Got ${m}`);
});
it('midpoint of 18:0006:00 is 00:00', () => {
const m = getMidnight(18.0, 6.0);
assert(approx(m, 0.0, 0.001), `Got ${m}`);
});
it('midpoint of 19:0005:00 is 00:00', () => {
const m = getMidnight(19.0, 5.0);
assert(approx(m, 0.0, 0.001), `Got ${m}`);
});
it('midpoint of 21:0003:00 is 00:00', () => {
const m = getMidnight(21.0, 3.0);
assert(approx(m, 0.0, 0.001), `Got ${m}`);
});
it('asymmetric night: 20:3004:30 gives 00:30', () => {
const m = getMidnight(20.5, 4.5);
assert(approx(m, 0.5, 0.001), `Got ${m}`);
});
it('late sunset: 21:3003:30 gives 00:30', () => {
const m = getMidnight(21.5, 3.5);
assert(approx(m, 0.5, 0.001), `Got ${m}`);
});
it('short night: 17:0007: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);