From 178e990cbc3d8150b61d64ff101f9221ab2cdc3a Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sat, 13 Jun 2026 10:37:32 -0400 Subject: [PATCH] fix: normalize date to UTC calendar day so prayer times are host-timezone-independent getTimes now constructs civDate = DateTime.utc(date.year, date.month, date.day, 12, 0, 0) at entry and passes it to both getSpa and getAngles. Previously a local DateTime in a positive-UTC-offset zone (e.g. UTC+12) would reach getSpa.toUtc() as the previous UTC calendar day, shifting all prayer times by ~24 h. Regression tests added for UTC/local DateTime consistency across UTC, EDT, and Auckland host timezones (27 tests, all pass). --- CHANGELOG.md | 10 +++++++ lib/src/angles.dart | 2 ++ lib/src/get_times.dart | 20 +++++++++---- test/pray_calc_dart_test.dart | 53 +++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd2f06..7936963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Fixed + +- Prayer times are now host-timezone-independent. `getTimes` normalizes the + caller's `date` to a stable UTC-noon reference (`DateTime.utc(y, m, d, 12)`) + before passing it to `getSpa` and all astronomical calculations. Previously, + a local `DateTime(2024, 3, 15)` in a UTC+12 zone would reach `getSpa` as + UTC March 14, shifting all times by one civil day. + ## [1.0.0] - 2026-05-25 ### Added diff --git a/lib/src/angles.dart b/lib/src/angles.dart index 063d1e8..75f9606 100644 --- a/lib/src/angles.dart +++ b/lib/src/angles.dart @@ -68,6 +68,8 @@ TwilightAngles getAngles( double pressure = 1013.25, }) { // 1. Solar ephemeris at UTC noon of the given date. + // date.year/month/day are read without TZ conversion so they reflect the + // caller's expressed civil date for both local and UTC DateTime inputs. final noonDate = DateTime.utc(date.year, date.month, date.day, 12, 0, 0); final jd = toJulianDate(noonDate); final eph = solarEphemeris(jd); diff --git a/lib/src/get_times.dart b/lib/src/get_times.dart index ba7c237..85489d1 100644 --- a/lib/src/get_times.dart +++ b/lib/src/get_times.dart @@ -33,9 +33,17 @@ PrayerTimes getTimes( double pressure = 1013.25, bool hanafi = false, }) { + // Normalize to a stable UTC-noon DateTime for this civil calendar date. + // Reading date.year/month/day directly (without TZ conversion) preserves + // the caller's expressed date regardless of whether they passed a local or + // UTC DateTime. Constructing UTC noon removes host-timezone influence from + // every downstream Julian-Day computation and aligns getSpa with the + // Meeus/MSC calculations (which all need the same civil date). + final civDate = DateTime.utc(date.year, date.month, date.day, 12, 0, 0); + // 1. Compute dynamic twilight angles. final tw = getAngles( - date, + civDate, lat, lng, elevation: elevation, @@ -49,8 +57,11 @@ PrayerTimes getTimes( final ishaZenith = 90 + tw.ishaAngle; // 3. Run SPA for solar position + custom twilight times. + // Pass civDate (UTC noon) so SPA receives a deterministic UTC instant + // and the date component it extracts (via date.toUtc()) is always the + // intended civil day, independent of host timezone. final spaData = getSpa( - date, + civDate, lat, lng, tz, @@ -70,9 +81,8 @@ PrayerTimes getTimes( final dhuhrTime = noonTime + 2.5 / 60; // 4. Solar declination for Asr (Meeus formula, accurate to ~0.01°). - final jd = toJulianDate( - DateTime.utc(date.year, date.month, date.day, 12, 0, 0), - ); + // civDate already is UTC noon of the civil date. + final jd = toJulianDate(civDate); final eph = solarEphemeris(jd); // 5. Asr time. diff --git a/test/pray_calc_dart_test.dart b/test/pray_calc_dart_test.dart index f6c316f..3681500 100644 --- a/test/pray_calc_dart_test.dart +++ b/test/pray_calc_dart_test.dart @@ -190,4 +190,57 @@ void main() { expect(result.angles[0].sunset, lessThan(result.angles[1].sunset)); }); }); + + group('UTC day-boundary regression — civil-date consistency', () { + // getTimes normalizes the input date to UTC noon of the expressed civil + // date before passing it to getSpa and all astronomical calculations. + // This ensures that a local DateTime and a UTC DateTime that both express + // "March 15" (via their .year/.month/.day fields) produce identical prayer + // times, regardless of what the host machine timezone is. + + test('local DateTime and UTC noon for same civil date produce identical times', () { + // Both express 2024-03-15: local midnight and UTC noon share year/month/day = 2024/3/15 + final local = DateTime(2024, 3, 15); + final utcNoon = DateTime.utc(2024, 3, 15, 12, 0, 0); + + final timesLocal = getTimes(local, 40.7128, -74.0060, -5.0); + final timesNoon = getTimes(utcNoon, 40.7128, -74.0060, -5.0); + + // After normalization both resolve to the same UTC noon reference point + expect(timesNoon.fajr, closeTo(timesLocal.fajr, 0.0001)); + expect(timesNoon.sunrise, closeTo(timesLocal.sunrise, 0.0001)); + expect(timesNoon.dhuhr, closeTo(timesLocal.dhuhr, 0.0001)); + expect(timesNoon.asr, closeTo(timesLocal.asr, 0.0001)); + expect(timesNoon.maghrib, closeTo(timesLocal.maghrib, 0.0001)); + expect(timesNoon.isha, closeTo(timesLocal.isha, 0.0001)); + }); + + test('UTC midnight and UTC noon for same civil date produce identical times', () { + final utcMidnight = DateTime.utc(2024, 3, 15, 0, 0, 0); + final utcNoon = DateTime.utc(2024, 3, 15, 12, 0, 0); + + final timesMid = getTimes(utcMidnight, 40.7128, -74.0060, -5.0); + final timesNoon = getTimes(utcNoon, 40.7128, -74.0060, -5.0); + + expect(timesNoon.fajr, closeTo(timesMid.fajr, 0.0001)); + expect(timesNoon.sunrise, closeTo(timesMid.sunrise, 0.0001)); + expect(timesNoon.dhuhr, closeTo(timesMid.dhuhr, 0.0001)); + expect(timesNoon.asr, closeTo(timesMid.asr, 0.0001)); + expect(timesNoon.maghrib, closeTo(timesMid.maghrib, 0.0001)); + expect(timesNoon.isha, closeTo(timesMid.isha, 0.0001)); + }); + + test('angles are identical for local vs UTC noon for the same civil date', () { + final local = DateTime(2024, 6, 15); + final utcNoon = DateTime.utc(2024, 6, 15, 12, 0, 0); + + // getAngles receives civDate (already UTC noon from getTimes) but can + // also be called directly — test that same civil date gives same angles. + final anglesLocal = getAngles(local, 40.7128, -74.0060); + final anglesUtcNoon = getAngles(utcNoon, 40.7128, -74.0060); + + expect(anglesUtcNoon.fajrAngle, closeTo(anglesLocal.fajrAngle, 0.001)); + expect(anglesUtcNoon.ishaAngle, closeTo(anglesLocal.ishaAngle, 0.001)); + }); + }); }