dart-lang / sdk

The Dart SDK, including the VM, JS and Wasm compilers, analysis, core libraries, and more.
https://dart.dev
BSD 3-Clause "New" or "Revised" License
10.21k stars 1.57k forks source link

[Feature request] Date class, without time #49426

Open Albert221 opened 2 years ago

Albert221 commented 2 years ago

I'm pretty surprised there is no such issue (or I just didn't find it).

I want to propose adding a Date class to the Dart core SDK.

It probably should have a few other things.

Currently, developers have to either implement such data structure with all the related logic and checks on their own or use one of the available solutions from The Community:

Other languages

lrhn commented 2 years ago

Related to #4195, probably others.

Quick and dirty implementation:

class Date {
  static const int _j1970jan1 = 2440588;
  final DateTime _date;
  Date(int year, int month, int day) : _date = DateTime.utc(year, month, day);
  Date.fromJulianDay(int julianDay) : this(-4713, 11, 24 + julianDay);

  int get year => _date.year;
  int get month => _date.month;
  int get day => _date.day;
  int get weekday => _date.weekday;
  int get julianDay => _date.millisecondsSinceEpoch ~/ 86400000 + _j1970jan1;

  Date update({int? year, int? month, int? day}) =>
      Date(year ?? _date.year, month ?? _date.month, day ?? _date.day);

  Date add({int years = 0, int months = 0, int days = 0}) =>
      Date(_date.year + years, _date.month + months, _date.day + days);

  Date subtract({int years = 0, int months = 0, int days = 0}) =>
      Date(_date.year - years, _date.month - months, _date.day - days);

  @override
  String toString() {
    var s = _date.toString();
    return s.substring(0, s.indexOf(" "));
  }
}

It's something we can do, but you can get very far by just using UTC-DateTime with zero time.

btrautmann commented 1 year ago

Would love this. I found myself chasing down a weird bug caused by the foot gun of doing DateTime "math" in local time.

  static Iterable<DateTime> daysFrom(Duration duration) {
    final numberOfDays = duration.inDays;
    final dates = List<DateTime>.generate(
      numberOfDays,
      (index) => nowUtc.subtract(Duration(days: index)), // Was previously using `DateTime.now()` instead of a UTC `DateTime`
    ).reversed.map((e) => e.toLocal().copyWith(hour: 0, minute: 0, second: 0, millisecond: 0, microsecond: 0));
    return dates;
  }

I have a use case where a server returns date objects (without time!) and I need the dates generated by my app to align with them. Using toLocal() adjusts the time, so "zero-ing" the DateTime object becomes necessary. It feels like a lot of dancing around to achieve what should be trivial.

lrhn commented 1 year ago

I'd probably just go with something like:

extension DateTimeDate on DateTime {
   bool get isDate => isUtc && millisecondsSinceEpoch % Duration.millisecondsPerDay == 0;
   DateTime toDate() => DateTime.utc(year, month, day);
}

With extension types, maybe it's worth creating a better API:

/// A calendar date in the proleptic Gregorian calendar.
///
/// Uses an UTC [DateTime] for all calculations, so has the same behavior and
/// limits as that.
// Comment out the following line until extension types are released.
// extension type Date._(DateTime _time) { /* 
class Date {
  final DateTime _time;
  Date._(this._time);
  String toString() => _time.toString();
//*/
  /// Calendar date of the [year], [month] and [day].
  ///
  /// The [month] and [day] are normalized to be in the range 1 through 12
  /// for months, and 1 through length-of-month for the day.
  /// Overflow or underflow is moved into the next larger unit, month
  /// or year.
  ///
  /// The normalized date must be in the range
  /// -271821-04-20 through 275760-09-13 (100_000_000 days to either side of 
  /// the Dart `DateTime` epoch of 1970-01-01).
  Date(int year, int month, int day) : this._(DateTime.utc(year, month, day));

  /// The calendar date of the [dateAndTime].
  Date.from(DateTime dateAndTime)
      : this(dateAndTime.year, dateAndTime.month, dateAndTime.day);

  /// Date of the [julianDay]th Julian Day.
  Date.fromJulianDay(int julianDay)
      : this._fromDaysSinceEpoch(julianDay - _julianDayOfEpoch);

  /// Date of [days] since 0000-01-01.
  Date.fromDaysSinceZero(int days)
      : this._fromDaysSinceEpoch(days - _zeroDayOfEpoch);

  /// Date of [days] since the arbitrary calendar epoch 1970-01-01.
  Date._fromDaysSinceEpoch(int days)
      : this._(DateTime.fromMillisecondsSinceEpoch(
            days * Duration.millisecondsPerDay,
            isUtc: true));

  /// Today's date.
  Date.today() : this.from(DateTime.timestamp());

  /// Parses a formatted date.
  ///
  /// Accepts the same formats as [DateTime.parse],
  /// and throws away the time.
  /// Throws a [FormatException] if the input is not accepted.
  static Date parse(String formattedDate) => Date.from(DateTime.parse(formattedDate));

  /// Tries to parse a formatted date.
  ///
  /// Accepts the same formats as [DateTime.parse],
  /// and throws away the time.
  /// Returns `null` if the input is not accepted.
  static Date? tryParse(String formattedDate) {
    var time = DateTime.tryParse(formattedDate);
    if (time == null) return null;
    return Date.from(time);
  }

  /// Calendar year.
  int get year => _time.year;

  /// Calendar month.
  ///
  /// Always in the range 1 through 12, representing January through December.
  int get month => _time.month;

  /// Day in month.
  ///
  /// Always a number in the range 1 through 31 for long months, 30 for shorter months,
  /// and 28 or 20 for February, depending on whether it's a leap year.
  int get day => _time.day;

  /// The number of days in the current month.
  int get daysInMonth =>
      const [31, null, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ??
      (isLeapYear ? 29 : 28);

  /// Day in year.
  ///
  /// The number of days in the year up to and including the current day.
  /// The day-in-year of the 1st of January is 1.
  ///
  /// A date `date` can be recreated by `Date(date.year, 1, date.dayInYear)`.
  int get dayInYear {
    var startOfYear = DateTime.utc(year, 1, 1);
    return 1 + (_time.millisecondsSinceEpoch -
            startOfYear.millisecondsSinceEpoch) ~/
        Duration.millisecondsPerDay;
  }

  /// Whether this year is a leap-year.
  ///
  /// A year in the proleptic Gregorian calendar is a leap year if:
  /// * It's divisible by 4, and
  /// * It's not divisible by 100, unless
  /// * It's also divisble by 400.
  ///
  /// This gives 97 leap years per 400 years.
  bool get isLeapYear {
    var year = this.year;
    return (year & 0x3 == 0) && ((year & 0xC != 0) || (year % 25 == 0));
  }

  /// Julian day of the day.
  ///
  /// This is the number of days since the epoch of the Julian calendar,
  /// which is -4713-11-24 in the proleptic Gregorian calendar.
  int get julianDay => _daysSinceEpoch - _julianDayOfEpoch;

  /// Whether this date is strictly before [other].
  bool operator <(Date other) => _daysSinceEpoch < other._daysSinceEpoch;

  /// Whether this date is no later than [other].
  bool operator <=(Date other) => _daysSinceEpoch <= other._daysSinceEpoch;

  /// Whether this date is strictly after [other].
  bool operator >(Date other) => _daysSinceEpoch > other._daysSinceEpoch;

  /// Whether this date is no earlier than [other].
  bool operator >=(Date other) => _daysSinceEpoch >= other._daysSinceEpoch;

  /// The number of whole days from this date to [other].
  ///
  /// Is negative if [other] is before this date.
  int dayDifference(Date other) => other._daysSinceEpoch - _daysSinceEpoch;

  /// A calendar date [days] later than this one, or earlier if [days] is negative.
  Date addDays(int days) => Date._fromDaysSinceEpoch(_daysSinceEpoch + days);

  /// Modifies the year, month and day by adding to their values.
  ///
  /// The [years], [monts] and [days] are added to
  /// the [year], [month] and [day] of the current date,
  /// then normalized to a valid calendar date.
  /// The added values can be negative.
  ///
  /// Doing `date.add(years: y, months: m, days: d)` is qquivalent
  /// to `Date(date.year + y, date.month + m, date.day + d)`.
  Date add({int years = 0, int months = 0, int days = 0}) =>
      Date(year + years, month + months, day + days);

  /// Updates the individual year, month or day to a new value.
  ///
  /// If the result is not a valid calendar date, either
  /// by directly setting an invalid value, like a month of 14,
  /// or by chaning the year and month so that the day is now
  /// greater than the length of the month, the date is
  /// normalized the same way as the [new Date] constructor.
  Date update({int? year, int? month, int? day}) =>
      Date(year ?? this.year, month ?? this.month, day ?? this.day);

  /// Entire calendar days since 1970-01-01.
  ///
  /// Complicated by extension types not being able to prevent
  /// any `DateTime` from being cast to `Date`.
  /// For `Date`s created using the extension type constructors,
  /// the `isUtc` is always true and the milliseconds are always
  /// a multiple of [Duration.millisecondsPerDay].
  int get _daysSinceEpoch {
    var ms = _time.millisecondsSinceEpoch;
    if (!_time.isUtc) {
      ms += _time.timeZoneOffset.inMilliseconds;
    }
    return ms ~/ Duration.millisecondsPerDay;
  }

  /// Creates a `DateTime` object with the current date, at midnight.
  ///
  /// The default is to create a UTC `DateTime`.
  /// If [local] is true, the created `DateTime` is local time.
  /// Be aware that some locations switch daylight saving time
  /// at midnight.
  DateTime toDateTime({bool local = false}) => local
      ? DateTime(year, month, day)
      : DateTime.fromMillisecondsSinceEpoch(
          _daysSinceEpoch * Duration.millisecondsPerDay);

  /// This date as a simple "year-month-day" string.
  ///
  /// If the year is negative, it starts with a minus sign.
  /// The year is padded to at least four digits, 
  /// the month and day to two digits.
  String toDateString() {
    var year = this.year;
    var month = this.month;
    var day = this.day;
    String yearString;
    if (year.abs() < 1000) {
      yearString = year.abs().toString().padLeft(4, '0');
      if (year < 0) yearString = "-$yearString";
    } else {
      yearString = year.toString();
    }
    return "$yearString-${month < 10 ? "0": ""}$month-${day < 10 ? "0" : ""}$day"; 
  }

  /// Days between -4713-11-24 and 1970-01-01.
  static const int _julianDayOfEpoch = 2440588;

  /// Days between 0000-01-01 and 1970-01-01
  static const int _zeroDayOfEpoch = 719528;
}

extension DateTimeDate on DateTime {
  /// Extracts the calendar date from this `DateTime`.
  Date toDate() => Date.from(this);

  /// Whether this date and time only contains a date.
  ///
  /// A date time is considered to only contain a date
  /// when it's in the UTC time zone, and is precisely
  /// at midnight (hours, minutes, seconds, and milliseconds
  /// are all zero).
  bool get isDate => isUtc && 
      (millisecondsSinceEpoch % Duration.millisecondsPerDay) == 0
}

(Obviously not tested at all!)

pschaffl commented 1 week ago

Even Java has got a proper date type: LocalDate The need for zeroing time when doing pure date calculations is an unnecessary source of bugs. I'm currently using time_machine but it does not seem to be maintained anymore.