From 848740c9f9043a2a6b6661ba1c1303b4841dfcf1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 12:50:38 +0300 Subject: [PATCH 1/2] feat(charts): time axis for line charts with date X columns (#1159) A line chart drew its X axis by row number, so dates were spaced evenly whatever the real gaps. When every X cell is an ISO date or timestamp, points are sorted by time and placed at their real distance, and the axis shows dates: hours, days or months, picked from the range. The SVG export does the same. Other X columns keep their row order. --- lib/features/results/charts/chart_data.dart | 96 ++++++++++++++++- lib/features/results/charts/chart_svg.dart | 26 ++++- .../results/charts/quick_chart_view.dart | 25 ++++- .../results/charts/chart_data_test.dart | 101 ++++++++++++++++++ 4 files changed, 240 insertions(+), 8 deletions(-) diff --git a/lib/features/results/charts/chart_data.dart b/lib/features/results/charts/chart_data.dart index cae8f10b..7b54102c 100644 --- a/lib/features/results/charts/chart_data.dart +++ b/lib/features/results/charts/chart_data.dart @@ -6,10 +6,13 @@ enum ChartAggregation { none, sum, count, avg, min, max } /// One labelled point of a chart series. class ChartPoint { - const ChartPoint(this.label, this.value); + const ChartPoint(this.label, this.value, {this.time}); final String label; final double value; + + /// The label as a date or timestamp, when the X column holds dates. + final DateTime? time; } /// Points ready to plot, plus how many parsed rows were left out of them. @@ -42,6 +45,84 @@ 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})$'); + + /// 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. + static DateTime? parseTime(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 (_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); + } + + /// Whether every non-empty cell of [column] is a date or timestamp. + static bool isTimeColumn(List> rows, int column) { + var seen = 0; + for (final row in rows) { + if (column >= row.length) continue; + final cell = row[column].trim(); + if (cell.isEmpty) continue; + if (parseTime(cell) == null) return false; + seen++; + } + return seen > 0; + } + + /// Earliest time among [points]; null unless every point has a time. + static DateTime? timeOrigin(List points) { + if (points.isEmpty || points.any((p) => p.time == null)) return null; + return points.map((p) => p.time!).reduce((a, b) => a.isBefore(b) ? a : b); + } + + /// Days from the earliest point for every point, so the X axis places them + /// at their real distance. Null unless every point has a time. + static List? timeOffsets(List points) { + final origin = timeOrigin(points); + if (origin == null) return null; + return [ + for (final p in points) + p.time!.difference(origin).inMicroseconds / Duration.microsecondsPerDay, + ]; + } + + /// Length of the time axis: from the earliest point to the latest. + static Duration timeSpan(List points) { + final origin = timeOrigin(points); + if (origin == null) return Duration.zero; + final last = points.map((p) => p.time!).reduce((a, b) => a.isAfter(b) ? a : b); + return last.difference(origin); + } + + /// Tick label for [time] on an axis spanning [span]: hours for a span under + /// two days, days under about 13 months, months beyond. + static String timeTickLabel(DateTime time, Duration span) { + String two(int v) => v.toString().padLeft(2, '0'); + if (span < const Duration(days: 2)) { + return '${two(time.hour)}:${two(time.minute)}'; + } + if (span < const Duration(days: 400)) { + return '${time.year}-${two(time.month)}-${two(time.day)}'; + } + return '${time.year}-${two(time.month)}'; + } + + /// Index of the point whose X offset is nearest to [x]. + static int nearestOffset(List offsets, double x) { + var best = 0; + for (var i = 1; i < offsets.length; i++) { + if ((offsets[i] - x).abs() < (offsets[best] - x).abs()) best = i; + } + return best; + } + /// Parses a cell as a finite number; `null` for empty / non numeric cells. static double? parseNumber(String raw) { final text = raw.trim(); @@ -110,6 +191,12 @@ class ChartData { } if (type == QuickChartType.line) { + // Dates on the X axis plot in time order, at their real distance. + if (labelColumn != null && + isTimeColumn(rows, labelColumn) && + points.every((p) => p.time != null)) { + points = [...points]..sort((a, b) => a.time!.compareTo(b.time!)); + } if (points.length <= maxPoints) return ChartSeries(points: points); return ChartSeries( points: points.sublist(0, maxPoints), @@ -191,7 +278,7 @@ class ChartData { final label = labelColumn != null && labelColumn < row.length ? row[labelColumn] : '${i + 1}'; - points.add(ChartPoint(label, v)); + points.add(ChartPoint(label, v, time: parseTime(label))); } return points; } @@ -203,17 +290,18 @@ class ChartData { ) { final groups = {}; for (final p in points) { - groups.putIfAbsent(p.label, _Group.new).add(p.value); + groups.putIfAbsent(p.label, () => _Group()..time = p.time).add(p.value); } return [ for (final e in groups.entries) - ChartPoint(e.key, e.value.result(aggregation)), + ChartPoint(e.key, e.value.result(aggregation), time: e.value.time), ]; } } /// Running totals for one X label. class _Group { + DateTime? time; double sum = 0; int count = 0; double min = double.infinity; diff --git a/lib/features/results/charts/chart_svg.dart b/lib/features/results/charts/chart_svg.dart index e61c2572..54d811aa 100644 --- a/lib/features/results/charts/chart_svg.dart +++ b/lib/features/results/charts/chart_svg.dart @@ -105,8 +105,16 @@ class ChartSvg { final fill = colors.series.first; final zero = y(0); final line = []; + // Line charts on a date X column place points at their real distance. + final offsets = bars ? null : ChartData.timeOffsets(points); + final spanDays = offsets == null ? 0.0 : offsets.reduce(math.max); + double xAt(int i) => offsets == null + ? _left + slot * (i + 0.5) + : spanDays == 0 + ? _left + plotW / 2 + : _left + plotW * offsets[i] / spanDays; for (var i = 0; i < points.length; i++) { - final cx = _left + slot * (i + 0.5); + final cx = xAt(i); final py = y(points[i].value); if (bars) { final w = slot * 0.7; @@ -118,12 +126,26 @@ class ChartSvg { } else { line.add('${_n(cx)},${_n(py)}'); } - if (i % step == 0) { + if (offsets == null && i % step == 0) { b.writeln('' '${escape(points[i].label)}'); } } + if (offsets != null) { + // Six dates on the time axis, labelled for the range they cover. + final origin = ChartData.timeOrigin(points)!; + final span = ChartData.timeSpan(points); + for (var k = 0; k <= 6; k++) { + final at = origin.add(Duration( + microseconds: + (spanDays * Duration.microsecondsPerDay * k / 6).round())); + final x = spanDays == 0 ? _left + plotW / 2 : _left + plotW * k / 6; + b.writeln('' + '${escape(ChartData.timeTickLabel(at, span))}'); + } + } if (!bars) { b.writeln(''); diff --git a/lib/features/results/charts/quick_chart_view.dart b/lib/features/results/charts/quick_chart_view.dart index 4c286d76..ef171eef 100644 --- a/lib/features/results/charts/quick_chart_view.dart +++ b/lib/features/results/charts/quick_chart_view.dart @@ -572,7 +572,27 @@ class _QuickChartViewState extends material.State { final slot = (width - 60) / shown; final rotate = slot < 60; + // Line charts on a date X column place points at their real distance; the + // axis then shows dates instead of point labels. + final offsets = _type == QuickChartType.line + ? ChartData.timeOffsets(points) + : null; + final origin = offsets == null ? null : ChartData.timeOrigin(points); + final span = offsets == null ? Duration.zero : ChartData.timeSpan(points); + final timeTick = offsets == null || span == Duration.zero + ? null + : span.inMicroseconds / Duration.microsecondsPerDay / 6; + material.Widget bottom(double v, TitleMeta meta) { + if (offsets != null && origin != null) { + final at = origin.add(Duration( + microseconds: (v * Duration.microsecondsPerDay).round())); + return SideTitleWidget( + meta: meta, + child: material.Text(ChartData.timeTickLabel(at, span), + style: label, maxLines: 1), + ); + } final i = v.toInt(); if (i < 0 || i >= points.length || i % step != 0) { return const material.SizedBox.shrink(); @@ -612,6 +632,7 @@ class _QuickChartViewState extends material.State { sideTitles: SideTitles( showTitles: true, reservedSize: rotate ? 52 : 28, + interval: timeTick, getTitlesWidget: bottom), ), leftTitles: AxisTitles( @@ -667,7 +688,7 @@ class _QuickChartViewState extends material.State { getTooltipItems: (spots) => [ for (final s in spots) LineTooltipItem( - '${points[s.x.toInt()].label}\n${ChartFormat.full(s.y)}', + '${points[offsets == null ? s.x.toInt() : ChartData.nearestOffset(offsets, s.x)].label}\n${ChartFormat.full(s.y)}', material.TextStyle( color: cs.popoverForeground, fontSize: 12)), ], @@ -677,7 +698,7 @@ class _QuickChartViewState extends material.State { LineChartBarData( spots: [ for (var i = 0; i < points.length; i++) - FlSpot(i.toDouble(), points[i].value), + FlSpot(offsets?[i] ?? i.toDouble(), points[i].value), ], color: palette.type1, barWidth: 2, diff --git a/test/features/results/charts/chart_data_test.dart b/test/features/results/charts/chart_data_test.dart index cdb35d92..518886b9 100644 --- a/test/features/results/charts/chart_data_test.dart +++ b/test/features/results/charts/chart_data_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/features/results/charts/chart_data.dart'; +import 'package:querya_desktop/features/results/charts/chart_svg.dart'; void main() { group('ChartData.parseNumber', () { @@ -246,4 +247,104 @@ void main() { expect(series.droppedRows, 80 - ChartData.maxPoints); }); }); + + group('ChartData time axis (#1159)', () { + test('parses ISO dates and timestamps, rejects other formats', () { + expect(ChartData.parseTime('2026-01-31'), DateTime.utc(2026, 1, 31)); + expect(ChartData.parseTime('2026-01-31 10:30'), + DateTime.utc(2026, 1, 31, 10, 30)); + expect(ChartData.parseTime('2026-01-31T10:30:00Z'), + DateTime.utc(2026, 1, 31, 10, 30)); + expect(ChartData.parseTime('31.01.2026'), isNull); + expect(ChartData.parseTime('abc'), isNull); + expect(ChartData.parseTime('2026-13-45'), isNull); + }); + + test('a column is a time column when every non-empty cell is a time', () { + expect(ChartData.isTimeColumn([['2026-01-01'], ['2026-01-02'], ['']], 0), + isTrue); + expect(ChartData.isTimeColumn([['2026-01-01'], ['x']], 0), isFalse); + expect(ChartData.isTimeColumn([['']], 0), isFalse); + }); + + test('dates place points at their real distance', () { + final series = ChartData.build( + rows: [ + ['2026-01-01', '1'], + ['2026-01-02', '2'], + ['2026-03-01', '3'], + ], + labelColumn: 0, + valueColumn: 1, + type: QuickChartType.line, + ); + final offsets = ChartData.timeOffsets(series.points)!; + expect(offsets, [0, 1, 58]); + // The gap before the third point is 58 times the first gap. + expect((offsets[2] - offsets[1]) / (offsets[1] - offsets[0]), 58); + }); + + test('date points come back in time order even when rows are not', () { + final series = ChartData.build( + rows: [ + ['2026-03-01', '3'], + ['2026-01-01', '1'], + ['2026-01-02', '2'], + ], + labelColumn: 0, + valueColumn: 1, + type: QuickChartType.line, + ); + expect(series.points.map((p) => p.label), + ['2026-01-01', '2026-01-02', '2026-03-01']); + expect(series.points.first.time, DateTime.utc(2026, 1, 1)); + }); + + test('a non-date X keeps row order and has no time offsets', () { + final series = ChartData.build( + rows: [ + ['b', '2'], + ['a', '1'], + ], + labelColumn: 0, + valueColumn: 1, + type: QuickChartType.line, + ); + expect(series.points.map((p) => p.label), ['b', 'a']); + expect(ChartData.timeOffsets(series.points), isNull); + }); + + test('tick labels follow the range: hours, days, then months', () { + final t = DateTime.utc(2026, 1, 5, 9, 7); + expect(ChartData.timeTickLabel(t, const Duration(hours: 6)), '09:07'); + expect(ChartData.timeTickLabel(t, const Duration(days: 30)), + '2026-01-05'); + expect(ChartData.timeTickLabel(t, const Duration(days: 900)), '2026-01'); + }); + + test('the SVG export places date points by time and labels dates', () { + final series = ChartData.build( + rows: [ + ['2026-01-01', '1'], + ['2026-01-02', '2'], + ['2026-03-01', '3'], + ], + labelColumn: 0, + valueColumn: 1, + type: QuickChartType.line, + ); + final svg = ChartSvg.build( + points: series.points, + type: QuickChartType.line, + colors: const ChartSvgColors( + background: '#ffffff', + text: '#000000', + grid: '#dddddd', + series: ['#1f77b4'], + ), + ); + expect(svg, contains('2026-03-01')); + expect(svg, contains('2026-01-01')); + }); + }); } From 0571e04fb6f0ef6d8583966cebb6b84bb77c964a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 12:54:21 +0300 Subject: [PATCH 2/2] fix(charts): reject impossible dates, correct the day count in the time-axis test (#1159) DateTime rolls an impossible date over, so 2026-13-45 parsed as 2027-02-14. The parsed fields must now match the text. 1 January to 1 March is 59 days, not 58: the gap ratio of 58 to 1 in the issue holds. --- lib/features/results/charts/chart_data.dart | 12 ++++++++++++ test/features/results/charts/chart_data_test.dart | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/features/results/charts/chart_data.dart b/lib/features/results/charts/chart_data.dart index 7b54102c..c9c8e19e 100644 --- a/lib/features/results/charts/chart_data.dart +++ b/lib/features/results/charts/chart_data.dart @@ -58,6 +58,18 @@ class ChartData { if (!_isoTime.hasMatch(text)) return null; final parsed = DateTime.tryParse(text.replaceFirst(' ', 'T')); 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))) { + 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); diff --git a/test/features/results/charts/chart_data_test.dart b/test/features/results/charts/chart_data_test.dart index 518886b9..acc23c93 100644 --- a/test/features/results/charts/chart_data_test.dart +++ b/test/features/results/charts/chart_data_test.dart @@ -279,7 +279,8 @@ void main() { type: QuickChartType.line, ); final offsets = ChartData.timeOffsets(series.points)!; - expect(offsets, [0, 1, 58]); + // 1 January to 1 March is 59 days; 2 January to 1 March is 58. + expect(offsets, [0, 1, 59]); // The gap before the third point is 58 times the first gap. expect((offsets[2] - offsets[1]) / (offsets[1] - offsets[0]), 58); });