Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 83 additions & 12 deletions lib/features/results/charts/chart_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,32 +45,103 @@ class ChartData {
/// Name of the slice that collects the categories beyond Top N.
static const String otherLabel = 'Other';

static final _isoTime = RegExp(
r'^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?(Z|[+-]\d{2}:?\d{2})?$',
);
static final _hasZone = RegExp(r'(Z|[+-]\d{2}:?\d{2})$');
static bool _digitsAt(String s, int at, int count) {
if (at + count > s.length) return false;
for (var i = at; i < at + count; i++) {
final c = s.codeUnitAt(i);
if (c < 0x30 || c > 0x39) return false;
}
return true;
}

/// The decimal value of the [count] digits at [at], or -1 when they are not
/// all digits.
static int _digitsValue(String s, int at, int count) {
if (!_digitsAt(s, at, count)) return -1;
var v = 0;
for (var i = at; i < at + count; i++) {
v = v * 10 + (s.codeUnitAt(i) - 0x30);
}
return v;
}

/// Checks [s] against
/// `YYYY-MM-DD([T ]HH:MM(:SS(.f+)?)?)?(Z|[+-]HH:?MM)?` without a regex:
/// -1 when it does not fit, 1 when it ends in a zone, 0 otherwise.
static int _scanIso(String s) {
final n = s.length;
if (n < 10 ||
!_digitsAt(s, 0, 4) ||
s.codeUnitAt(4) != 0x2D ||
!_digitsAt(s, 5, 2) ||
s.codeUnitAt(7) != 0x2D ||
!_digitsAt(s, 8, 2)) {
return -1;
}
var i = 10;
if (i < n) {
final sep = s.codeUnitAt(i);
if (sep == 0x54 || sep == 0x20) {
// [T ]HH:MM
if (!_digitsAt(s, i + 1, 2) ||
i + 3 >= n ||
s.codeUnitAt(i + 3) != 0x3A ||
!_digitsAt(s, i + 4, 2)) {
return -1;
}
i += 6;
// :SS
if (i < n && s.codeUnitAt(i) == 0x3A && _digitsAt(s, i + 1, 2)) {
i += 3;
// .f+
if (i < n && s.codeUnitAt(i) == 0x2E && _digitsAt(s, i + 1, 1)) {
i++;
while (i < n && _digitsAt(s, i, 1)) {
i++;
}
}
}
}
}
if (i == n) return 0;
final z = s.codeUnitAt(i);
if (z == 0x5A) return i + 1 == n ? 1 : -1; // Z
if (z != 0x2B && z != 0x2D) return -1; // + or -
i++;
if (!_digitsAt(s, i, 2)) return -1;
i += 2;
if (i < n && s.codeUnitAt(i) == 0x3A) i++;
if (!_digitsAt(s, i, 2)) return -1;
return i + 2 == n ? 1 : -1;
}

/// Parses an ISO date (`2026-01-31`) or timestamp (`2026-01-31 10:00`,
/// `2026-01-31T10:00:00Z`). Without a zone the wall-clock fields are kept, so
/// a day stays a day whatever the machine's time zone is.
///
/// Called for every cell of a candidate X column (up to 100,000 rows), so the
/// shape check and the field read-back work on code units: no regex, no
/// substring, no `replaceFirst` (#1514).
static DateTime? parseTime(String raw) {
final text = raw.trim();
if (!_isoTime.hasMatch(text)) return null;
final parsed = DateTime.tryParse(text.replaceFirst(' ', 'T'));
final zone = _scanIso(text);
if (zone < 0) return null;
// DateTime.tryParse takes a space as well as `T` between date and time.
final parsed = DateTime.tryParse(text);
if (parsed == null) return null;
// DateTime rolls an impossible date over (2026-13-45 becomes 2027-02-14);
// the fields must read back as typed.
if (parsed.year != int.parse(text.substring(0, 4)) ||
parsed.month != int.parse(text.substring(5, 7)) ||
parsed.day != int.parse(text.substring(8, 10))) {
if (parsed.year != _digitsValue(text, 0, 4) ||
parsed.month != _digitsValue(text, 5, 2) ||
parsed.day != _digitsValue(text, 8, 2)) {
return null;
}
if (text.length >= 16 &&
(parsed.hour != int.parse(text.substring(11, 13)) ||
parsed.minute != int.parse(text.substring(14, 16)))) {
(parsed.hour != _digitsValue(text, 11, 2) ||
parsed.minute != _digitsValue(text, 14, 2))) {
return null;
}
if (_hasZone.hasMatch(text)) return parsed.toUtc();
if (zone == 1) return parsed.toUtc();
return DateTime.utc(parsed.year, parsed.month, parsed.day, parsed.hour,
parsed.minute, parsed.second, parsed.millisecond, parsed.microsecond);
}
Expand Down
123 changes: 123 additions & 0 deletions test/features/results/charts/chart_data_parse_time_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import 'dart:math';

import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/features/results/charts/chart_data.dart';

/// `ChartData.parseTime` before #1514 (regex, replaceFirst, substrings): the
/// reference the code-unit version has to agree with.
final _isoTime = RegExp(
r'^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?(Z|[+-]\d{2}:?\d{2})?$',
);
final _hasZone = RegExp(r'(Z|[+-]\d{2}:?\d{2})$');

DateTime? _reference(String raw) {
final text = raw.trim();
if (!_isoTime.hasMatch(text)) return null;
final parsed = DateTime.tryParse(text.replaceFirst(' ', 'T'));
if (parsed == null) return null;
if (parsed.year != int.parse(text.substring(0, 4)) ||
parsed.month != int.parse(text.substring(5, 7)) ||
parsed.day != int.parse(text.substring(8, 10))) {
return null;
}
if (text.length >= 16 &&
(parsed.hour != int.parse(text.substring(11, 13)) ||
parsed.minute != int.parse(text.substring(14, 16)))) {
return null;
}
if (_hasZone.hasMatch(text)) return parsed.toUtc();
return DateTime.utc(parsed.year, parsed.month, parsed.day, parsed.hour,
parsed.minute, parsed.second, parsed.millisecond, parsed.microsecond);
}

void main() {
group('ChartData.parseTime scanner (#1514)', () {
test('agrees with the previous implementation on listed values', () {
const cases = <String>[
'',
' ',
'2026-01-31',
' 2026-01-31 ',
'2026-01-31 10:30',
'2026-01-31T10:30',
'2026-01-31T10:30:15',
'2026-01-31T10:30:15.123',
'2026-01-31T10:30:15.123456',
'2026-01-31T10:30:15.',
'2026-01-31T10:30:1',
'2026-01-31T10:3',
'2026-01-31T10',
'2026-01-31T10:30Z',
'2026-01-31T10:30:00Z',
'2026-01-31 10:30:00+02:00',
'2026-01-31 10:30:00-0530',
'2026-01-31 10:30:00+02',
'2026-01-31 10:30:00 +02:00',
'2026-01-31Z',
'2026-01-31+01:00',
'2026-01-31+0100',
'2026-13-45',
'2026-02-30',
'2026-02-29',
'2024-02-29',
'2026-01-31T25:00',
'2026-01-31T10:61',
'2026-01-31T24:00',
'2026-1-31',
'26-01-31',
'31.01.2026',
'2026/01/31',
'2026-01-31x',
'2026-01-31T10:30:00zZ',
'2026-01-31t10:30',
'2026-01-31 10:30',
'0000-01-01',
'9999-12-31T23:59:59.999Z',
'2026-01-31',
];
for (final raw in cases) {
expect(ChartData.parseTime(raw), _reference(raw), reason: '"$raw"');
}
});

test('agrees with the previous implementation on random strings', () {
const pieces = <String>[
'2026', '2024', '0000', '99', '01', '12', '13', '31', '45', '10', '24',
'60', '-', ':', '.', ' ', 'T', 'Z', '+', 'z', 't', '5', 'x',
];
final random = Random(1514);
for (var n = 0; n < 6000; n++) {
// Mostly shaped like a date, with random pieces dropped and swapped in.
final parts = <String>[
'2026', '-', '01', '-', '31',
if (random.nextBool()) ...[
random.nextBool() ? 'T' : ' ',
'10', ':', '30',
if (random.nextBool()) ...[':', '15'],
if (random.nextBool()) ...['.', '123'],
],
if (random.nextBool()) ...[
random.nextBool() ? 'Z' : '+',
'02', if (random.nextBool()) ':', '00',
],
];
for (var k = random.nextInt(3); k > 0; k--) {
parts[random.nextInt(parts.length)] =
pieces[random.nextInt(pieces.length)];
}
final raw = parts.join();
expect(ChartData.parseTime(raw), _reference(raw), reason: '"$raw"');
}
});

test('a 100,000 row column is checked in one pass', () {
final rows = [
for (var i = 0; i < 100000; i++)
['2026-01-${(i % 28 + 1).toString().padLeft(2, '0')} 10:30:00'],
];
expect(ChartData.isTimeColumn(rows, 0), isTrue);
rows.add(['not a date']);
expect(ChartData.isTimeColumn(rows, 0), isFalse);
});
});
}
Loading