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.2k stars 1.57k forks source link

`DateTime.parse()` omit timezone #54993

Closed ethicnology closed 1 week ago

ethicnology commented 7 months ago

DateTime is able to parse a string with timezone such as 2024-02-22 16:00:00-05:00 and convert it into 2024-02-22 21:00:00.000Z setting timeZoneOffset to 0, we are losing the timezone information in the conversion.

I know toLocal() to convert the DateTime to the timezone of the device, but not matching the timezone parsed in the original string

Is there a way to

var x = DateTime.parse('2024-02-22 16:00:00-05:00', tz: true);
print(x.timeZoneOffset) // -5
lrhn commented 7 months ago

No. A Dart DateTime has no way to store a time zone, so it cannot remember which time zone the original was parsed from. Dart DateTime (like the JavaScript Date that is used to implement it on web) can either be UTC time, or it can be "local time", which has no time zone. Either is stored as microseconds since Epoch. It is a timestamp representing a specific point in time, which can be displayed as either a UTC time or as whatever the current system says its time zone would be.

That is, the time zone is either UTC, or there is no time zone.

When a local-time DateTime needs a time zone, like someone reading timeZoneOffset, it asks the local system what the time zone would be for that point in time. It doesn't remember it for later, and if your OS decides that your local time zone has changed (because you told it, or you took your laptop to another time zone), and ask about the timezoneOffset of the same local-time DateTime object again, you'll get another answer.

There is just no way for the value to represent a time zone of -05:00, it simply has no place to store that information.

It could have had, but adding too much extra functionality on top of what the JavaScript Date class has, was not a priority for the platform DateTime. That should rather be handled by a dedicated package, or packages, depending on what features are actually needed. You'll need a library that actually understands time zones. I don't think package:intl handles time zones, so something else. (Or just someone providing a (DateTime utcDateTime, (int hours, int minutes) timeZoneOffset) parseWithTimeZone(String isoString) and toTimeZoneString(DateTime, [(int hours, int minutes)? timeZone]) which parses and emits ISO strings for that point in time..)

ethicnology commented 7 months ago

Thank you for the detailed answer. Yes, I got frustrated when I realized that I couldn't modify timeZoneOffset to any timezone.

Since it's important to my use case to keep tracking timezones, I created something like this to keep the time in UTC and the timezone separately with the ability to convert toLocal timezone. But it would be great if we can handle this case with standard DateTime.

class DateTimeTz {
  late DateTime datetime;
  late Duration timezone;

  DateTimeTz({required this.datetime, required this.timezone});

  DateTimeTz.now() {
    datetime = DateTime.now().toUtc();
    timezone = datetime.toLocal().timeZoneOffset;
  }

  // Returns 2024-02-22 15:39:17-05:00
  String toRfc3339() => _toCustom() + _formatTimeZone();

  // Parse a RFC3339 string with timezone
  // Cancel the timezone conversion by adding up the timezone
  // Keep the timezone as it
  static DateTimeTz fromRfc3339(String input) {
    var timezone = DateTimeTz._parseTimeZone(input);
    return DateTimeTz(
      datetime: DateTime.parse(input).add(timezone),
      timezone: timezone,
    );
  }

  // Add the timezone and remove the trailing Z
  String toLocal() => datetime.add(timezone).toString().replaceAll("Z", "");

  // Returns -05:00
  String _formatTimeZone() {
    int minutes = timezone.inMinutes;
    return '${minutes >= 0 ? '+' : '-'}'
        '${(minutes ~/ 60).abs().toString().padLeft(2, '0')}:'
        '${(minutes % 60).abs().toString().padLeft(2, '0')}';
  }

  // Returns 2024-02-22 15:39:17
  String _toCustom() {
    return '${datetime.year.toString().padLeft(4, '0')}-'
        '${datetime.month.toString().padLeft(2, '0')}-'
        '${datetime.day.toString().padLeft(2, '0')} '
        '${datetime.hour.toString().padLeft(2, '0')}:'
        '${datetime.minute.toString().padLeft(2, '0')}:'
        '${datetime.second.toString().padLeft(2, '0')}';
  }

  // Returns timezone Duration from a rfc3339 string
  static Duration _parseTimeZone(String rfc3339) {
    if (rfc3339.endsWith('Z')) {
      return const Duration(minutes: 0);
    } else {
      RegExp regex = RegExp(r'([-+]\d{2}:\d{2}|Z)$');
      Match? match = regex.firstMatch(rfc3339);
      if (match != null) {
        String timezone = match.group(1)!;
        int hours = int.parse(timezone.substring(0, 3));
        int minutes = int.parse(timezone.substring(4));
        int offsetMinutes = hours * 60 + minutes;
        return Duration(minutes: offsetMinutes);
      } else {
        throw Exception("No timezone found.");
      }
    }
  }
}
thumbert commented 7 months ago

@ethicnology Are you aware of the excellent package timezone on pub.dev? It does parsing with timezones as you need.

ethicnology commented 7 months ago

Actually, I see it as a way to have a database of timezone and DateTime make easy to convert a DateTime to one of the timezone available in the package.

I already have the timezoneOffset returned as timestamptz from postgres, but I need to keep the information of the timezone when I deserialize such value and to ability to display it as a rfc3339.

I may misunderstand it, but how would you refactor the provided code using timezone ?

thumbert commented 7 months ago

Hi,

You will stop using Dart's DateTime from the sdk completely and only use TZDateTime. Something like:

import 'package:timezone/data/latest.dart';
import 'package:timezone/standalone.dart';

void main() {
  initializeTimeZones();
  final dt = TZDateTime.utc(2024, 2, 24, 15);
  print(dt.toIso8601String());  // 2024-02-24T15:00:00.000Z

  // If I understand you correctly, you'll store the UTC time and the timezone separately.  
  // Then this is how you construct the datetime in the timezone of interest. 
  final location = getLocation('America/New_York');
  final dt1 = TZDateTime.fromMillisecondsSinceEpoch(location, dt.millisecondsSinceEpoch);
  print(dt1.toIso8601String());  // 2024-02-24T10:00:00.000-0500

  // to parse a string in a given timezone
  final dt2 = TZDateTime.parse(location, '2024-02-22 15:00:00-05:00');
  print(dt2.toIso8601String());  // 2024-02-22T15:00:00.000-0500
}

Feel free to email me directly if you need more help. I'm pretty sure the package timezone has you covered.

Best, T

adimshev commented 1 week ago

Look at DateTime.parse implementation. It could be so simple...

image
ethicnology commented 1 week ago

I ended up by extending DateTime with a toIso8601WithTz(Duration timezone) and I'm storing the timezone independently.

Thank you all for your contributions