Properly handle timezones

This should (hopefully 🤞) take care of problems saving entries with timezone
information.
This commit is contained in:
Roland Geider
2025-11-18 15:32:23 +01:00
parent e0b0aba979
commit fc881c4929
25 changed files with 130 additions and 118 deletions

View File

@@ -16,6 +16,8 @@ jobs:
test:
name: Run tests
runs-on: ubuntu-latest
env:
TZ: Europe/Berlin
steps:
- uses: actions/checkout@v5

View File

@@ -16,11 +16,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/// Returns a timezone aware DateTime object from a date and time string.
DateTime getDateTimeFromDateAndTime(String date, String time) {
return DateTime.parse('$date $time');
}
/// Returns a list of [DateTime] objects from [first] to [last], inclusive.
List<DateTime> daysInRange(DateTime first, DateTime last) {
final dayCount = last.difference(first).inDays + 1;

View File

@@ -62,6 +62,14 @@ String dateToUtcIso8601(DateTime dateTime) {
return dateTime.toUtc().toIso8601String();
}
/// Converts an ISO8601 datetime string in UTC to a local DateTime object.
///
/// Needs to be used in conjunction with [dateToUtcIso8601] in the models to
/// correctly handle timezones.
DateTime utcIso8601ToLocalDate(String dateTime) {
return DateTime.parse(dateTime).toLocal();
}
/*
* Converts a time to a date object.
* Needed e.g. when the wger api only sends a time but no date information.

View File

@@ -29,7 +29,7 @@ class WeightEntry {
@JsonKey(required: true, fromJson: stringToNum, toJson: numToString)
late num weight = 0;
@JsonKey(required: true)
@JsonKey(required: true, fromJson: utcIso8601ToLocalDate, toJson: dateToUtcIso8601)
late DateTime date;
WeightEntry({this.id, weight, DateTime? date}) {

View File

@@ -11,12 +11,12 @@ WeightEntry _$WeightEntryFromJson(Map<String, dynamic> json) {
return WeightEntry(
id: (json['id'] as num?)?.toInt(),
weight: stringToNum(json['weight'] as String?),
date: json['date'] == null ? null : DateTime.parse(json['date'] as String),
date: utcIso8601ToLocalDate(json['date'] as String),
);
}
Map<String, dynamic> _$WeightEntryToJson(WeightEntry instance) => <String, dynamic>{
'id': instance.id,
'weight': numToString(instance.weight),
'date': instance.date.toIso8601String(),
'date': dateToUtcIso8601(instance.date),
};

View File

@@ -26,7 +26,7 @@ class Image {
@JsonKey(required: true)
int? id;
@JsonKey(required: true, toJson: dateToYYYYMMDD)
@JsonKey(required: true, fromJson: utcIso8601ToLocalDate, toJson: dateToUtcIso8601)
late DateTime date;
@JsonKey(required: true, name: 'image')

View File

@@ -10,7 +10,7 @@ Image _$ImageFromJson(Map<String, dynamic> json) {
$checkKeys(json, requiredKeys: const ['id', 'date', 'image']);
return Image(
id: (json['id'] as num?)?.toInt(),
date: DateTime.parse(json['date'] as String),
date: utcIso8601ToLocalDate(json['date'] as String),
url: json['image'] as String?,
description: json['description'] as String? ?? '',
);
@@ -18,7 +18,7 @@ Image _$ImageFromJson(Map<String, dynamic> json) {
Map<String, dynamic> _$ImageToJson(Image instance) => <String, dynamic>{
'id': instance.id,
'date': dateToYYYYMMDD(instance.date),
'date': dateToUtcIso8601(instance.date),
'image': instance.url,
'description': instance.description,
};

View File

@@ -36,7 +36,7 @@ class Log {
@JsonKey(required: true, name: 'plan')
int planId;
@JsonKey(required: true, toJson: dateToUtcIso8601)
@JsonKey(required: true, fromJson: utcIso8601ToLocalDate, toJson: dateToUtcIso8601)
late DateTime datetime;
String? comment;

View File

@@ -25,7 +25,7 @@ Log _$LogFromJson(Map<String, dynamic> json) {
weightUnitId: (json['weight_unit'] as num?)?.toInt(),
amount: stringToNum(json['amount'] as String?),
planId: (json['plan'] as num).toInt(),
datetime: DateTime.parse(json['datetime'] as String),
datetime: utcIso8601ToLocalDate(json['datetime'] as String),
comment: json['comment'] as String?,
);
}

View File

@@ -41,7 +41,12 @@ class NutritionalPlan {
@JsonKey(required: true)
late String description;
@JsonKey(required: true, name: 'creation_date', toJson: dateToUtcIso8601)
@JsonKey(
required: true,
name: 'creation_date',
fromJson: utcIso8601ToLocalDate,
toJson: dateToUtcIso8601,
)
late DateTime creationDate;
@JsonKey(required: true, name: 'start', toJson: dateToYYYYMMDD)

View File

@@ -26,9 +26,7 @@ NutritionalPlan _$NutritionalPlanFromJson(Map<String, dynamic> json) {
return NutritionalPlan(
id: (json['id'] as num?)?.toInt(),
description: json['description'] as String,
creationDate: json['creation_date'] == null
? null
: DateTime.parse(json['creation_date'] as String),
creationDate: utcIso8601ToLocalDate(json['creation_date'] as String),
startDate: DateTime.parse(json['start'] as String),
endDate: json['end'] == null ? null : DateTime.parse(json['end'] as String),
onlyLogging: json['only_logging'] as bool? ?? false,

View File

@@ -80,7 +80,7 @@ class Log {
@JsonKey(includeFromJson: false, includeToJson: false)
late WeightUnit? weightUnitObj;
@JsonKey(required: true, toJson: dateToUtcIso8601)
@JsonKey(required: true, fromJson: utcIso8601ToLocalDate, toJson: dateToUtcIso8601)
late DateTime date;
Log({

View File

@@ -39,7 +39,7 @@ Log _$LogFromJson(Map<String, dynamic> json) {
weight: stringToNum(json['weight'] as String?),
weightTarget: stringToNum(json['weight_target'] as String?),
weightUnitId: (json['weight_unit'] as num?)?.toInt() ?? WEIGHT_UNIT_KG,
date: DateTime.parse(json['date'] as String),
date: utcIso8601ToLocalDate(json['date'] as String),
)..sessionId = (json['session'] as num?)?.toInt();
}

View File

@@ -42,7 +42,7 @@ class Routine {
@JsonKey(required: true, includeToJson: false)
int? id;
@JsonKey(required: true, toJson: dateToUtcIso8601)
@JsonKey(required: true, fromJson: utcIso8601ToLocalDate, toJson: dateToUtcIso8601)
late DateTime created;
@JsonKey(required: true, name: 'name')

View File

@@ -9,11 +9,19 @@ part of 'routine.dart';
Routine _$RoutineFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'created', 'name', 'description', 'fit_in_week', 'start', 'end'],
requiredKeys: const [
'id',
'created',
'name',
'description',
'fit_in_week',
'start',
'end',
],
);
return Routine(
id: (json['id'] as num?)?.toInt(),
created: json['created'] == null ? null : DateTime.parse(json['created'] as String),
created: utcIso8601ToLocalDate(json['created'] as String),
name: json['name'] as String,
start: json['start'] == null ? null : DateTime.parse(json['start'] as String),
end: json['end'] == null ? null : DateTime.parse(json['end'] as String),

View File

@@ -9,7 +9,15 @@ part of 'session.dart';
WorkoutSession _$WorkoutSessionFromJson(Map<String, dynamic> json) {
$checkKeys(
json,
requiredKeys: const ['id', 'routine', 'day', 'date', 'impression', 'time_start', 'time_end'],
requiredKeys: const [
'id',
'routine',
'day',
'date',
'impression',
'time_start',
'time_end',
],
);
return WorkoutSession(
id: (json['id'] as num?)?.toInt(),

View File

@@ -17,8 +17,8 @@
*/
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/date.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/nutrition/meal.dart';
@@ -44,14 +44,13 @@ class LogMealScreen extends StatefulWidget {
class _LogMealScreenState extends State<LogMealScreen> {
double portionPct = 100;
final _dateController = TextEditingController();
final _dateController = TextEditingController(text: '');
final _timeController = TextEditingController();
@override
void initState() {
super.initState();
_dateController.text = dateToYYYYMMDD(DateTime.now())!;
_timeController.text = timeToString(TimeOfDay.now())!;
}
@@ -64,6 +63,9 @@ class _LogMealScreenState extends State<LogMealScreen> {
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode);
final i18n = AppLocalizations.of(context);
final args = ModalRoute.of(context)!.settings.arguments as LogMealArguments;
final meal = args.meal.copyWith(
mealItems: args.meal.mealItems
@@ -71,7 +73,9 @@ class _LogMealScreenState extends State<LogMealScreen> {
.toList(),
);
final i18n = AppLocalizations.of(context);
if (_dateController.text.isEmpty) {
_dateController.text = dateFormat.format(DateTime.now());
}
return Scaffold(
appBar: AppBar(title: Text(i18n.logMeal)),
@@ -123,12 +127,12 @@ class _LogMealScreenState extends State<LogMealScreen> {
final pickedDate = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(DateTime.now().year - 10),
firstDate: DateTime.now().subtract(const Duration(days: 3000)),
lastDate: DateTime.now(),
);
if (pickedDate != null) {
_dateController.text = dateToYYYYMMDD(pickedDate)!;
_dateController.text = dateFormat.format(pickedDate);
}
},
onSaved: (newValue) {
@@ -170,15 +174,13 @@ class _LogMealScreenState extends State<LogMealScreen> {
TextButton(
child: Text(i18n.save),
onPressed: () async {
final loggedTime = getDateTimeFromDateAndTime(
_dateController.text,
_timeController.text,
final loggedDate = dateFormat.parse(
'${_dateController.text} ${_timeController.text}',
);
await Provider.of<NutritionPlansProvider>(
context,
listen: false,
).logMealToDiary(meal, loggedTime);
).logMealToDiary(meal, loggedDate);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -20,9 +20,9 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/gallery/image.dart' as gallery;
import 'package:wger/providers/gallery.dart';
@@ -43,7 +43,7 @@ class _ImageFormState extends State<ImageForm> {
XFile? _file;
final dateController = TextEditingController();
final dateController = TextEditingController(text: '');
final TextEditingController descriptionController = TextEditingController();
@override
@@ -57,7 +57,6 @@ class _ImageFormState extends State<ImageForm> {
void initState() {
super.initState();
dateController.text = dateToYYYYMMDD(widget._image.date)!;
descriptionController.text = widget._image.description;
}
@@ -97,6 +96,12 @@ class _ImageFormState extends State<ImageForm> {
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode);
if (dateController.text.isEmpty) {
dateController.text = dateFormat.format(widget._image.date);
}
return Form(
key: _form,
child: Column(
@@ -156,14 +161,15 @@ class _ImageFormState extends State<ImageForm> {
final pickedDate = await showDatePicker(
context: context,
initialDate: widget._image.date,
firstDate: DateTime(DateTime.now().year - 10),
firstDate: DateTime.now().subtract(const Duration(days: 3000)),
lastDate: DateTime.now(),
);
dateController.text = dateToYYYYMMDD(pickedDate)!;
if (pickedDate != null) {
dateController.text = dateFormat.format(pickedDate);
}
},
onSaved: (newValue) {
widget._image.date = DateTime.parse(newValue!);
widget._image.date = dateFormat.parse(newValue!);
},
validator: (value) {
if (widget._image.id == null && _file == null) {

View File

@@ -20,7 +20,6 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/measurements/measurement_category.dart';
import 'package:wger/models/measurements/measurement_entry.dart';
@@ -136,7 +135,7 @@ class MeasurementEntryForm extends StatelessWidget {
final _form = GlobalKey<FormState>();
final int _categoryId;
final _valueController = TextEditingController();
final _dateController = TextEditingController();
final _dateController = TextEditingController(text: '');
final _notesController = TextEditingController();
late final Map<String, dynamic> _entryData;
@@ -158,18 +157,23 @@ class MeasurementEntryForm extends StatelessWidget {
_entryData['notes'] = entry.notes;
}
_dateController.text = dateToYYYYMMDD(_entryData['date'])!;
_valueController.text = '';
_notesController.text = _entryData['notes']!;
}
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode);
final measurementProvider = Provider.of<MeasurementProvider>(context, listen: false);
final measurementCategory = measurementProvider.categories.firstWhere(
(category) => category.id == _categoryId,
);
if (_dateController.text.isEmpty) {
_dateController.text = dateFormat.format(_entryData['date']);
}
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
// If the value is not empty, format it
@@ -213,10 +217,10 @@ class MeasurementEntryForm extends StatelessWidget {
},
);
_dateController.text = pickedDate == null ? '' : dateToYYYYMMDD(pickedDate)!;
_dateController.text = pickedDate == null ? '' : dateFormat.format(pickedDate);
},
onSaved: (newValue) {
_entryData['date'] = DateTime.parse(newValue!);
_entryData['date'] = dateFormat.parse(newValue!);
},
validator: (value) {
if (value!.isEmpty) {

View File

@@ -20,7 +20,6 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/date.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/nutrition/ingredient.dart';
@@ -182,18 +181,10 @@ class IngredientFormState extends State<IngredientForm> {
final _ingredientIdController = TextEditingController();
final _amountController = TextEditingController();
final _dateController = TextEditingController(); // optional
final _timeController = TextEditingController(); // optional
final _timeController = TextEditingController(text: ''); // optional
final _mealItem = MealItem.empty();
var _searchQuery = ''; // copy from typeahead. for filtering suggestions
@override
void initState() {
super.initState();
final now = DateTime.now();
_dateController.text = dateToYYYYMMDD(now)!;
_timeController.text = timeToString(TimeOfDay.fromDateTime(now))!;
}
@override
void dispose() {
_ingredientController.dispose();
@@ -236,6 +227,17 @@ class IngredientFormState extends State<IngredientForm> {
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode);
final timeFormat = DateFormat.Hm(Localizations.localeOf(context).languageCode);
if (_dateController.text.isEmpty) {
_dateController.text = dateFormat.format(DateTime.now());
}
if (_timeController.text.isEmpty) {
_timeController.text = timeFormat.format(DateTime.now());
}
final String unit = AppLocalizations.of(context).g;
final queryLower = _searchQuery.toLowerCase();
final suggestions = widget.recent
@@ -311,7 +313,7 @@ class IngredientFormState extends State<IngredientForm> {
);
if (pickedDate != null) {
_dateController.text = dateToYYYYMMDD(pickedDate)!;
_dateController.text = dateFormat.format(pickedDate);
}
},
onSaved: (newValue) {
@@ -402,9 +404,8 @@ class IngredientFormState extends State<IngredientForm> {
_form.currentState!.save();
_mealItem.ingredientId = int.parse(_ingredientIdController.text);
final loggedDate = getDateTimeFromDateAndTime(
_dateController.text,
_timeController.text,
final loggedDate = dateFormat.parse(
'${_dateController.text} ${_timeController.text}',
);
widget.onSave(context, _mealItem, loggedDate);

View File

@@ -1,31 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* wger Workout Manager is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_test/flutter_test.dart';
import 'package:wger/helpers/date.dart';
void main() {
group('getDateTimeFromDateAndTime', () {
test('should correctly generate a DateTime', () {
expect(
getDateTimeFromDateAndTime('2025-05-16', '17:02'),
DateTime(2025, 5, 16, 17, 2),
);
});
});
}

View File

@@ -57,13 +57,20 @@ void main() {
});
});
group('dateToIsoWithTimezone', () {
group('Iso8601 and timezones', () {
test('should format DateTime to a string with timezone', () {
expect(
dateToUtcIso8601(DateTime.parse('2025-05-16T18:15:00+02:00')),
'2025-05-16T16:15:00.000Z',
);
});
test('should convert an iso8601 datetime to local', () {
expect(
utcIso8601ToLocalDate('2025-11-18T18:15:00+08:00'),
DateTime.parse('2025-11-18T11:15:00.000'),
);
});
});
group('stringToTime', () {

View File

@@ -23,13 +23,13 @@ void main() {
group('fetchPost', () {
test('Test that the weight entries are correctly converted to json', () {
expect(
WeightEntry(id: 1, weight: 80, date: DateTime(2020, 12, 31, 12, 34)).toJson(),
{'id': 1, 'weight': '80', 'date': '2020-12-31T12:34:00.000'},
WeightEntry(id: 1, weight: 80, date: DateTime.utc(2020, 12, 31, 12, 34)).toJson(),
{'id': 1, 'weight': '80', 'date': '2020-12-31T12:34:00.000Z'},
);
expect(
WeightEntry(id: 2, weight: 70.2, date: DateTime(2020, 12, 01)).toJson(),
{'id': 2, 'weight': '70.2', 'date': '2020-12-01T00:00:00.000'},
WeightEntry(id: 2, weight: 70.2, date: DateTime.utc(2020, 12, 01)).toJson(),
{'id': 2, 'weight': '70.2', 'date': '2020-12-01T00:00:00.000Z'},
);
});
@@ -53,12 +53,11 @@ void main() {
group('model', () {
test('Test the individual values from the model', () {
WeightEntry weightModel;
//_weightModel = WeightEntry();
weightModel = WeightEntry(id: 1, weight: 80, date: DateTime(2020, 10, 01));
weightModel = WeightEntry(id: 1, weight: 80, date: DateTime.utc(2020, 10, 01));
expect(weightModel.id, 1);
expect(weightModel.weight, 80);
expect(weightModel.date, DateTime(2020, 10, 01));
expect(weightModel.date, DateTime.utc(2020, 10, 01));
});
});
}

View File

@@ -72,14 +72,14 @@ void main() {
when(mockBaseProvider.makeUrl(any, query: anyNamed('query'))).thenReturn(uri);
when(
mockBaseProvider.post(
{'id': null, 'weight': '80', 'date': '2021-01-01T00:00:00.000'},
{'id': null, 'weight': '80', 'date': '2021-01-01T00:00:00.000Z'},
uri,
),
).thenAnswer((_) => Future.value({'id': 25, 'date': '2021-01-01', 'weight': '80'}));
// Act
final BodyWeightProvider provider = BodyWeightProvider(mockBaseProvider);
final WeightEntry weightEntry = WeightEntry(date: DateTime(2021, 1, 1), weight: 80);
final WeightEntry weightEntry = WeightEntry(date: DateTime.utc(2021, 1, 1), weight: 80);
final WeightEntry weightEntryNew = await provider.addEntry(weightEntry);
// Assert

View File

@@ -18,8 +18,8 @@
import 'package:wger/models/body_weight/weight_entry.dart';
final testWeightEntry1 = WeightEntry(id: 1, weight: 80, date: DateTime(2021, 01, 01, 15, 30));
final testWeightEntry2 = WeightEntry(id: 2, weight: 81, date: DateTime(2021, 01, 10, 10, 0));
final testWeightEntry1 = WeightEntry(id: 1, weight: 80, date: DateTime.utc(2021, 01, 01, 15, 30));
final testWeightEntry2 = WeightEntry(id: 2, weight: 81, date: DateTime.utc(2021, 01, 10, 10, 0));
List<WeightEntry> getWeightEntries() {
return [testWeightEntry1, testWeightEntry2];
@@ -27,20 +27,20 @@ List<WeightEntry> getWeightEntries() {
List<WeightEntry> getScreenshotWeightEntries() {
return [
WeightEntry(id: 1, weight: 86, date: DateTime(2021, 01, 01)),
WeightEntry(id: 2, weight: 81, date: DateTime(2021, 01, 10)),
WeightEntry(id: 3, weight: 82, date: DateTime(2021, 01, 20)),
WeightEntry(id: 4, weight: 83, date: DateTime(2021, 01, 30)),
WeightEntry(id: 5, weight: 86, date: DateTime(2021, 02, 20)),
WeightEntry(id: 6, weight: 90, date: DateTime(2021, 02, 28)),
WeightEntry(id: 7, weight: 91, date: DateTime(2021, 03, 20)),
WeightEntry(id: 8, weight: 91.1, date: DateTime(2021, 03, 30)),
WeightEntry(id: 9, weight: 90, date: DateTime(2021, 05, 1)),
WeightEntry(id: 10, weight: 91, date: DateTime(2021, 6, 5)),
WeightEntry(id: 11, weight: 89, date: DateTime(2021, 6, 20)),
WeightEntry(id: 12, weight: 88, date: DateTime(2021, 7, 15)),
WeightEntry(id: 13, weight: 86, date: DateTime(2021, 7, 20)),
WeightEntry(id: 14, weight: 83, date: DateTime(2021, 7, 30)),
WeightEntry(id: 15, weight: 80, date: DateTime(2021, 8, 10)),
WeightEntry(id: 1, weight: 86, date: DateTime.utc(2021, 01, 01)),
WeightEntry(id: 2, weight: 81, date: DateTime.utc(2021, 01, 10)),
WeightEntry(id: 3, weight: 82, date: DateTime.utc(2021, 01, 20)),
WeightEntry(id: 4, weight: 83, date: DateTime.utc(2021, 01, 30)),
WeightEntry(id: 5, weight: 86, date: DateTime.utc(2021, 02, 20)),
WeightEntry(id: 6, weight: 90, date: DateTime.utc(2021, 02, 28)),
WeightEntry(id: 7, weight: 91, date: DateTime.utc(2021, 03, 20)),
WeightEntry(id: 8, weight: 91.1, date: DateTime.utc(2021, 03, 30)),
WeightEntry(id: 9, weight: 90, date: DateTime.utc(2021, 05, 1)),
WeightEntry(id: 10, weight: 91, date: DateTime.utc(2021, 6, 5)),
WeightEntry(id: 11, weight: 89, date: DateTime.utc(2021, 6, 20)),
WeightEntry(id: 12, weight: 88, date: DateTime.utc(2021, 7, 15)),
WeightEntry(id: 13, weight: 86, date: DateTime.utc(2021, 7, 20)),
WeightEntry(id: 14, weight: 83, date: DateTime.utc(2021, 7, 30)),
WeightEntry(id: 15, weight: 80, date: DateTime.utc(2021, 8, 10)),
];
}