Repository: jama5262/jiffy

Issue:
`Jiffy.parse` ignores `Z` timezone suffix in ISO 8601 date/time string
**Describe the bug**

```dart
final timestamp = '2024-07-02T08:07:50Z';

final a = DateTime.parse(timestamp);
// a = DateTime (2024-07-02 08:07:50.000Z)

final b = DateTime.parse(timestamp).toUtc();
// b = DateTime (2024-07-02 08:07:50.000Z)

final c = Jiffy.parse(timestamp).dateTime;
// c = DateTime (2024-07-02 08:07:50.000)

final d = Jiffy.parse(timestamp).dateTime.toUtc();
// d = DateTime (2024-07-02 14:07:50.000Z)
```

In `c`, the timezone is missing, even though the `Z` suffix is present in the string `timestamp`.

Consequently, the `DateTime` is created in the local timezone (by the looks of it). This leads to conversion to UTC being wrong (`d`).

Jiffy 6.3.1.


Discussion: Hey @lukehutch , that's correct. By default, Jiffy parses the timestamp as local time. To parse a UTC string as UTC, you need to tell `Jiffy` that the string you are parsing is a UTC by setting the `isUtc` variable to `true`, like this:

```dart
final timestampUtc = '2024-07-02T08:07:50Z';
final c = Jiffy.parse(timestampUtc, isUtc: true);
print(c.isUtc); // this will print true
```

You can find more info about string parsing here https://github.com/jama5262/jiffy/tree/master/doc#string-formatting

Hope this solves your issue
Please do reopen this issue if the above solution did not solve your problem
@jama5262 then Jiffy does not conform to even a useful subset of the ISO 8601 standard! And the user does not even get a warning that their date was parsed wrongly. The `Z` is the correct way to specify a UTC date, not a Boolean argument! The boolean argument is only necessary if there is some non-confoemant system that does not specify the `Z`. The `Z` should not be silently ignored!

I don't have a way to reopen this, but it needs to be reopened. This is a horrible bug, and the "solution" is not a solution. 

If you insist that Jiffy should not detect the `Z` (which would be a very, very bad idea), then at least please throw an exception if the date ends in `Z`, otherwise you are going to create really bad bugs in user code. It took days to find the source of this bug in my own system. 
@lukehutch Understandable, yeah Jiffy should automatically detect it 

Will work on a fix 👍
ISO 8601 is a large standard, but this is an important part of it. 

Since you agreed to work on a fix (thank you), the other important part is to parse the `+`/`-` timezone suffix. That really needs to be done too. 

Fix patch:
diff --git a/doc/README.md b/doc/README.md
index 09a102b..7570728 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -22,11 +22,15 @@ Jiffy.now().format(); // Returns an ISO 8601 formatted date-time
 You can create a Jiffy instance by parsing a string representation of a date.
 
 ```dart
-Jiffy.parse('2021-05-25'); // Standard date format (YYYY-MM-DD)
-Jiffy.parse('2021/05/25'); // Date format using slashes
-Jiffy.parse('2021-05-25 12:00:00.000'); // Date with time
-Jiffy.parse('2021-05-25T12:00:00.000'); // ISO date format
-Jiffy.parse('2021-05-25T12:00:00.000Z'); // ISO UTC format
+Jiffy.parse('2021/05/25'); // Standard date format (YYYY/MM/DD)
+
+Jiffy.parse('2021-05-25 12:00:00.000'); // Parse from Dart's DateTime to string format
+
+// ISO formats
+Jiffy.parse('2021-05-25'); // ISO Date format
+Jiffy.parse('2021-05-25T12:00:00.000'); // ISO date time format
+Jiffy.parse('2021-05-25T12:00:00.000Z'); // ISO date time UTC format
+Jiffy.parse('2021-05-25T12:00:00.000+02:00'); // ISO date time UTC offset format
 ```
 
 ## Parsing with Custom String Patterns
diff --git a/lib/src/parser.dart b/lib/src/parser.dart
index 3dc61de..4e107b6 100644
--- a/lib/src/parser.dart
+++ b/lib/src/parser.dart
@@ -24,15 +24,11 @@ class Parser {
           .copyWith(isUtc: isUtc);
     }
 
-    if (_matchesHyphenStringDateTime(input)) {
-      return _parseString(locale.code, input, 'yyyy-MM-dd')
-          .copyWith(isUtc: isUtc);
-    } else if (_matchesSlashStringDateTime(input)) {
-      return _parseString(locale.code, input, 'yyyy/MM/dd')
-          .copyWith(isUtc: isUtc);
-    } else if (_matchesDartStringDateTime(input) ||
-        _matchesISOStringDateTime(input)) {
-      return DateTime.parse(input).copyWith(isUtc: isUtc);
+    if (_matchesSlashStringDateTime(input)) {
+      return _parseString(locale.code, input, 'yyyy/MM/dd');
+    } else if (_matchesIsoDateTime(input) ||
+        _matchesDartStringDateTime(input)) {
+      return DateTime.parse(input);
     } else {
       throw JiffyException(
           'Could not read date time of input `$input`, try using a pattern, '
@@ -93,18 +89,6 @@ class Parser {
         .replaceFirst(_matchesOrdinalDates(input, ordinals), '');
   }
 
-  String _matchesOrdinalDates(String input, Ordinals ordinals) {
-    final matches =
-        // ignore: prefer_interpolation_to_compose_strings
-        RegExp(r'\d+\s*(' + ordinals.asList().join('|') + ')')
-            .allMatches(input);
-    return matches.isNotEmpty ? matches.first.group(1) ?? '' : '';
-  }
-
-  bool _matchesHyphenStringDateTime(String input) {
-    return RegExp(r'\d{4}-\d{1,2}-\d{1,2}$').hasMatch(input);
-  }
-
   bool _matchesSlashStringDateTime(String input) {
     return RegExp(r'\d{4}/\d{1,2}/\d{1,2}$').hasMatch(input);
   }
@@ -115,9 +99,41 @@ class Parser {
         .hasMatch(input);
   }
 
-  bool _matchesISOStringDateTime(String input) {
-    return RegExp(
-            r'\d{4}-\d{1,2}-\d{1,2}T\d{1,2}(:\d{1,2})?(:\d{1,2})?(.\d+)?(Z?)$')
+  bool _matchesIsoDate(String input) {
+    return RegExp(r'\d{4}-\d{1,2}-\d{1,2}$').hasMatch(input);
+  }
+
+  bool _matchesIsoLocalDateTime(String input) {
+    return RegExp(r'^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])'
+            r'T([01][0-9]|2[0-3]):([0-5][0-9])(:([0-5][0-9]|60)(\.\d+)?)?$')
         .hasMatch(input);
   }
+
+  bool _matchesIsoUtcDateTime(String input) {
+    return RegExp(r'^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])'
+            r'T([01][0-9]|2[0-3]):([0-5][0-9])(:([0-5][0-9]|60)(\.\d+)?)?Z$')
+        .hasMatch(input);
+  }
+
+  bool _matchesIsoUtcOffsetDateTime(String input) {
+    return RegExp(r'^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])'
+            r'T([01][0-9]|2[0-3]):([0-5][0-9])(:([0-5][0-9]|60)(\.\d+)?)?'
+            r'([+-](0[0-9]|1[0-4]):([0-5][0-9]))$')
+        .hasMatch(input);
+  }
+
+  bool _matchesIsoDateTime(String input) {
+    return _matchesIsoDate(input) ||
+        _matchesIsoLocalDateTime(input) ||
+        _matchesIsoUtcDateTime(input) ||
+        _matchesIsoUtcOffsetDateTime(input);
+  }
+
+  String _matchesOrdinalDates(String input, Ordinals ordinals) {
+    final matches =
+        // ignore: prefer_interpolation_to_compose_strings
+        RegExp(r'\d+\s*(' + ordinals.asList().join('|') + ')')
+            .allMatches(input);
+    return matches.isNotEmpty ? matches.first.group(1) ?? '' : '';
+  }
 }


Test patch:
diff --git a/test/src/parser_test.dart b/test/src/parser_test.dart
index 5f747b3..1466415 100644
--- a/test/src/parser_test.dart
+++ b/test/src/parser_test.dart
@@ -68,8 +68,8 @@ void main() {
               contains(expectedExceptionMessage))));
     });
 
-    for (var testData in fromStringWithBasicDateTimeTestData()) {
-      test('Should successfully parse basic datetime format', () {
+    for (var testData in fromStringWithSlashDateTimeTestData()) {
+      test('Should successfully parse slash datetime format', () {
         // Setup
         final pattern = null;
 
@@ -82,7 +82,7 @@ void main() {
       });
     }
 
-    for (var testData in fromStringWithDateTimeAndISOTestData()) {
+    for (var testData in fromStringWithIsoTestData()) {
       test('Should successfully parse DateTime and ISO datetime format', () {
         // Setup
         final pattern = null;
@@ -96,6 +96,20 @@ void main() {
       });
     }
 
+    for (var testData in fromStringWithDateTimeTestData()) {
+      test('Should successfully parse DateTime datetime format', () {
+        // Setup
+        final pattern = null;
+
+        // Execute
+        final actualDateTime =
+            underTest.fromString(testData['input'], pattern, locale, false);
+
+        // Verify
+        expect(actualDateTime, testData['expected']);
+      });
+    }
+
     test('Should throw JiffyException if input datetime cannot be parsed', () {
       // Setup
       final input = 'invalid-input-date-time';
@@ -271,30 +285,97 @@ List<Map<String, dynamic>> fromStringWithPatternAMAndPMTestData() {
   ];
 }
 
-List<Map<String, dynamic>> fromStringWithBasicDateTimeTestData() {
+List<Map<String, dynamic>> fromStringWithSlashDateTimeTestData() {
   return [
-    {'input': '1997-09-23', 'expected': DateTime(1997, 9, 23)},
-    {'input': '1997-9-02', 'expected': DateTime(1997, 9, 2)},
-    {'input': '1997-12-2', 'expected': DateTime(1997, 12, 2)},
     {'input': '2022/02/25', 'expected': DateTime(2022, 2, 25)},
     {'input': '2022/5/05', 'expected': DateTime(2022, 5, 5)},
     {'input': '2022/10/5', 'expected': DateTime(2022, 10, 5)}
   ];
 }
 
-List<Map<String, dynamic>> fromStringWithDateTimeAndISOTestData() {
+List<Map<String, dynamic>> fromStringWithIsoTestData() {
   return [
     {
-      'input': '2022-12-26 11:18:12.946621',
-      'expected': DateTime(2022, 12, 26, 11, 18, 12, 946, 621)
+      'input': '2025-03-22',
+      'expected': DateTime(2025, 03, 22, 0, 0, 0, 0, 0),
+    },
+    {
+      'input': '2025-03-22T14:30',
+      'expected': DateTime(2025, 03, 22, 14, 30, 0, 0, 0),
+    },
+    {
+      'input': '2025-03-22T14:30:45',
+      'expected': DateTime(2025, 03, 22, 14, 30, 45, 0, 0),
+    },
+    {
+      'input': '2025-03-22T14:30:45.123',
+      'expected': DateTime(2025, 03, 22, 14, 30, 45, 123, 0),
+    },
+    {
+      'input': '2025-03-22T14:30:45.123876',
+      'expected': DateTime(2025, 03, 22, 14, 30, 45, 123, 876),
+    },
+    {
+      'input': '2025-03-22T14:30Z',
+      'expected': DateTime(2025, 03, 22, 14, 30, 0, 0, 0).copyWith(isUtc: true),
+    },
+    {
+      'input': '2025-03-22T14:30:45Z',
+      'expected':
+          DateTime(2025, 03, 22, 14, 30, 45, 0, 0).copyWith(isUtc: true),
+    },
+    {
+      'input': '2025-03-22T14:30:45.123Z',
+      'expected':
+          DateTime(2025, 03, 22, 14, 30, 45, 123, 0).copyWith(isUtc: true),
+    },
+    {
+      'input': '2025-03-22T14:30:45.123876Z',
+      'expected':
+          DateTime(2025, 03, 22, 14, 30, 45, 123, 876).copyWith(isUtc: true),
+    },
+    {
+      'input': '2025-03-22T14:00:00+00:00',
+      'expected': DateTime(2025, 03, 22, 14, 0, 0, 0, 0).copyWith(isUtc: true),
+    },
+    {
+      'input': '2025-03-22T15:00:00+01:00',
+      'expected': DateTime(2025, 03, 22, 14, 0, 0, 0, 0).copyWith(isUtc: true)
     },
     {
-      'input': '2022-12-26T11:18:12.947031',
-      'expected': DateTime(2022, 12, 26, 11, 18, 12, 947, 031)
+      'input': '2025-03-22T09:00:00-05:00',
+      'expected': DateTime(2025, 03, 22, 14, 0, 0, 0, 0).copyWith(isUtc: true)
+    },
+    {
+      'input': '2025-03-22T19:30:00+05:30',
+      'expected': DateTime(2025, 03, 22, 14, 0, 0, 0, 0).copyWith(isUtc: true)
     }
   ];
 }
 
+List<Map<String, dynamic>> fromStringWithDateTimeTestData() {
+  return [
+    {
+      'input': '2022-12-26 11:18:12.946621',
+      'expected': DateTime(2022, 12, 26, 11, 18, 12, 946, 621),
+    },
+    {
+      'input': '2022-12-26 11:18:12.946',
+      'expected': DateTime(2022, 12, 26, 11, 18, 12, 946, 0),
+    },
+    {
+      'input': '2022-12-26 11:18:12.946Z',
+      'expected':
+          DateTime(2022, 12, 26, 11, 18, 12, 946, 0).copyWith(isUtc: true),
+    },
+    {
+      'input': '2022-12-26 11:18:12.946123Z',
+      'expected':
+          DateTime(2022, 12, 26, 11, 18, 12, 946, 123).copyWith(isUtc: true),
+    },
+  ];
+}
+
 List<Map<String, dynamic>> fromListTestData() {
   return [
     {