← Back to engineering

Instant-based DateTime to NepaliDateTime Conversion

Why instant-based UTC conversion fixes off-by-one Nepali date shifts and how to adopt it safely.

2026-05-0110 min read
dart datetime timezone nepali_utils

This post explains the motivation, design, and behavior of the instant-based DateTimeNepaliDateTime 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.

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.inDays relative to an AD reference date (1913-04-13).
  • Walk through the BS calendar using this difference.

This has two main issues:

  1. Local civil days are not always 24 hours

    • In Dart's Nepal timezone data, 1986-01-01 -> 1986-01-02 is only 23 hours 45 minutes.
    • Using .inDays over such irregular days can produce 0 instead of 1, shifting the computed BS date by one day.
  2. 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.
  • BS date vs. time-of-day are separated

    • BS date comes from walking _nepaliYears using integer difference.
    • 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.
  • 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.

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:

  1. picked is 2026-03-03 00:00 in Nepal.
  2. picked.toUtc() gives 2026-03-02 18:15:00Z.
  3. The algorithm computes a day index from _nepaliEpochUtc to this instant.
  4. That day index maps to a BS date via _nepaliYears.
  5. inNepalTime = inputUtc + 5:45 gives back 2026-03-03 00:00 in Nepal.
  6. The resulting NepaliDateTime has:
    • 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 1913 to 2100:
      • 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, and day match the original date.

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(...).inDays between local DateTimes.
  • 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:

  1. toUtc() -> 2026-03-02 18:15:00Z.
  2. Compute integer day index from _nepaliEpochUtc.
  3. Map index to BS date.
  4. 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:00 aligns with AD 1913-04-13 00:00:00 in 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.