mirror of
https://github.com/acamarata/pray-calc.git
synced 2026-06-30 19:04:26 +00:00
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:
parent
43d6241ac2
commit
4534b41957
12 changed files with 166 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
24
src/getMidnight.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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]>;
|
||||
|
|
|
|||
12
test-cjs.cjs
12
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);
|
||||
|
|
|
|||
100
test.mjs
100
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue