Instant-based DateTime to NepaliDateTime Conversion
Why instant-based UTC conversion fixes off-by-one Nepali date shifts and how to adopt it safely.
This post explains the motivation, design, and behavior of the instant-based DateTime →
NepaliDateTime conversion in nepali_utils.
During testing, a separate method (toNepaliDateTimeInstant()) was used so callers were not disrupted. After validation,
toNepaliDateTime() is the recommended entry point for instant-based conversion. The legacy implementation is
toNepaliDateTimePreInstant() for code that intentionally depends on the old behavior.
-
nepali_utilspackage: https://pub.dev/packages/nepali_utils - API docs: https://pub.dev/documentation/nepali_utils/latest
- Fork with the integration work (pre-merge): https://github.com/Ayushrestha05/nepali_utils
The instant-based conversion changes are not merged into the main package yet. To try them locally, pin your app or package with a
dependency_override pointing at branch ayush/instant-based-updates:
dependency_overrides:
nepali_utils:
git:
url: https://github.com/Ayushrestha05/nepali_utils.git
ref: ayush/instant-based-updates
When the PR lands on pub, drop the override and use the published version constraint as usual.
Overview
-
toNepaliDateTime(): Default conversion — instant-based (UTC day index). Use this for new and migrated code. -
toNepaliDateTimePreInstant(): Previous implementation (local truncation +.inDays). More fragile near midnight and around historical timezone anomalies; prefer only where you explicitly need parity with older behavior.
toNepaliDateTime() derives the BS calendar date from the exact instant (via UTC), not from naive local-calendar truncation. That avoids rare but serious off-by-one shifts near midnight or around historical timezone changes.
Problem with the previous approach
The previous implementation (toNepaliDateTimePreInstant()) did:
- Convert the input to Nepal time.
- Truncate to the local date (
DateTime(now.year, now.month, now.day)). - Compute
difference.inDaysrelative to an AD reference date (1913-04-13). - Walk through the BS calendar using this
difference.
This has two main issues:
-
Local civil days are not always 24 hours
- In Dart's Nepal timezone data,
1986-01-01 -> 1986-01-02is only 23 hours 45 minutes. - Using
.inDaysover such irregular days can produce0instead of1, shifting the computed BS date by one day.
- In Dart's Nepal timezone data,
-
Timezone-dependent behavior
- The code relies on the device's local timezone and its historical rules.
- A special-case hack was added for the 1986 anomaly.
- This only fixes that specific case in Nepal time and does not help for:
- Other historical changes.
- Running the same code in a non-Nepal timezone.
As a result, some users observed occasional 1-day shifts in converted Nepali dates, depending on timezone, date, and time of day.
New approach: instant-based conversion in UTC
Default toNepaliDateTime() is based on a fixed epoch instant and pure UTC day counting.
Fixed epoch
We define a single reference instant in UTC:
- Nepali:
1970-01-01 00:00:00(BS) - AD in Nepal:
1913-04-13 00:00:00(Nepal time) - AD in UTC:
1913-04-12 18:15:00Z
In code:
final DateTime _nepaliEpochUtc = DateTime.utc(1913, 4, 12, 18, 15, 0);
This is a true instant in time, not a local date. Every other DateTime can be compared to this instant in UTC.
Core algorithm
extension ENepaliDateTime on DateTime {
NepaliDateTime toNepaliDateTime() {
const nepalTzOffset = Duration(hours: 5, minutes: 45);
final inputUtc = toUtc();
final inNepalTime = inputUtc.add(nepalTzOffset);
// 1) Pure UTC-based day index since epoch.
var difference = inputUtc.difference(_nepaliEpochUtc).inDays;
// 2) Start from BS 1970-01-01 and walk forward.
var nepaliYear = 1970;
var nepaliMonth = 1;
var nepaliDay = 1;
var daysInYear = _nepaliYears[nepaliYear]!.first;
while (difference >= daysInYear) {
nepaliYear += 1;
difference -= daysInYear;
daysInYear = _nepaliYears[nepaliYear]!.first;
}
var daysInMonth = _nepaliYears[nepaliYear]![nepaliMonth];
while (difference >= daysInMonth) {
difference -= daysInMonth;
nepaliMonth += 1;
daysInMonth = _nepaliYears[nepaliYear]![nepaliMonth];
}
nepaliDay += difference;
// 3) Attach Nepal-local time-of-day from the same instant.
return NepaliDateTime(
nepaliYear,
nepaliMonth,
nepaliDay,
inNepalTime.hour,
inNepalTime.minute,
inNepalTime.second,
inNepalTime.millisecond,
inNepalTime.microsecond,
);
}
}
Why this avoids date shifts
-
Pure UTC timeline
- We compute
difference = (inputUtc - _nepaliEpochUtc).inDays. - UTC does not have DST or irregular day lengths. Every day is exactly 86,400 seconds.
- This makes the day index stable and timezone-independent.
- We compute
-
BS date vs. time-of-day are separated
- BS date comes from walking
_nepaliYearsusing integerdifference. - Time-of-day comes from the same instant in Nepal local time.
- Timezone quirks affect the clock, but cannot change which BS date you land on.
- BS date comes from walking
-
Works regardless of device timezone
- Timezone enters only in the initial
toUtc()conversion. - Once in UTC, the rest of the algorithm is the same everywhere.
- Timezone enters only in the initial
Add and subtract
Add/subtract on NepaliDateTime depended on converting between AD instants and BS dates in ways that inherited the same local-day and
.inDays problems as the old conversion path. Those paths now rely on instant-based conversion, so calendar arithmetic aligns with corrected date mapping instead of accumulating separate edge-case fixes.
Example: user in Nepal picks a date
Imagine a date picker in Nepal returns 2026-03-03 with time 00:00:
final picked = DateTime(2026, 3, 3); // device timezone: Asia/Kathmandu
final bs = picked.toNepaliDateTime();
Steps:
pickedis2026-03-03 00:00in Nepal.picked.toUtc()gives2026-03-02 18:15:00Z.- The algorithm computes a day index from
_nepaliEpochUtcto this instant. - That day index maps to a BS date via
_nepaliYears. inNepalTime = inputUtc + 5:45gives back2026-03-03 00:00in Nepal.-
The resulting
NepaliDateTimehas:- The correct BS date for that instant.
- Time-of-day
00:00:00.000000(midnight).
No matter where the code runs, the same instant yields the same BS date.
Testing strategy
To verify there are no hidden off-by-one issues, add a dedicated test:
- File:
test/nepali_date_time_instant_test.dart -
Test approach:
- For every AD date from
1913to2100:- Construct the instant corresponding to Nepal midnight for that AD date.
- Convert to BS using
toNepaliDateTime(). - Convert back to AD using
NepaliDateTime.toDateTime(). - Assert that AD
year,month, anddaymatch the original date.
- For every AD date from
If there were any systematic 1-day shifts, this test would fail somewhere in that range.
Recommended usage
Use the instant-based conversion by calling toNepaliDateTime():
final ad = DateTime.now(); // any timezone
final bs = ad.toNepaliDateTime();
If you specifically need parity with earlier releases (local truncation + .inDays semantics), call
toNepaliDateTimePreInstant(), understanding it is weaker near midnight and around historical TZ anomalies.
In general, for correctness across timezones and a wide AD range, use toNepaliDateTime()
and upgraded add/subtract behavior.
FAQs
What is the purpose of converting to UTC first?
Converting to UTC gives a single continuous timeline without DST or historical offset quirks. We compute
difference = (inputUtc - _nepaliEpochUtc).inDays, a stable timezone-independent day index. Once the BS date is determined from this index, local timezone rules can no longer cause 1-day shifts.
What exactly was wrong with the previous implementation?
That path — now exposed as toNepaliDateTimePreInstant() — did date math in local civil time:
-
It truncated to local midnight and used
.difference(...).inDaysbetween localDateTimes. - Some historical days in Nepal are not exactly 24h long.
- That caused off-by-one BS dates, partially hidden by a manual adjustment.
This approach was fragile, timezone-dependent, and did not generalize well.
What happens if the user is already in Nepal time and selects a date like 2026-03-03 (00:00)?
If a device in Nepal creates DateTime(2026, 3, 3) from a date picker, conversion does:
toUtc()->2026-03-02 18:15:00Z.- Compute integer day index from
_nepaliEpochUtc. - Map index to BS date.
- Add Nepal offset back to get
inNepalTime = 2026-03-03 00:00.
The resulting NepaliDateTime has the correct BS date and hour = 0, minute = 0, with no date shift.
Why does using the full instant matter near midnight?
Near midnight, the same civil date can correspond to different UTC instants across timezones. If you only use local calendar date and ignore time, you can cross a BS day boundary without noticing. Using the full instant in UTC ties BS date to the exact moment in time.
Why that specific reference instant?
It preserves the existing library mapping:
- BS
1970-01-01 00:00:00aligns with AD1913-04-13 00:00:00in Nepal. - With UTC+5:45, that instant is
1913-04-12 18:15:00Z.
Choosing this epoch keeps the calendar table aligned while allowing safe instant-based day math.