From 102942efc8887163d70e1cc1955971546ccca951 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 28 Jan 2025 17:21:05 +0000 Subject: [PATCH 01/19] Add failing test for the update script --- tests/UpdateScripts/ConvertDatesToUtcTest.php | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/UpdateScripts/ConvertDatesToUtcTest.php diff --git a/tests/UpdateScripts/ConvertDatesToUtcTest.php b/tests/UpdateScripts/ConvertDatesToUtcTest.php new file mode 100644 index 0000000000..e9a273b315 --- /dev/null +++ b/tests/UpdateScripts/ConvertDatesToUtcTest.php @@ -0,0 +1,76 @@ +assertUpdateScriptRegistered(ConvertDatesToUtc::class); + } + + #[Test] + public function is_skipped_when_application_timezone_is_utc() + { + $this->markTestIncomplete(); + } + + #[Test] + public function it_can_convert_date_fields_in_entries() + { + config()->set('app.timezone', 'America/New_York'); // -05:00 + date_default_timezone_set('America/New_York'); + + $collection = tap(Collection::make('articles')->dated(true))->save(); + + $collection->entryBlueprint()->setContents([ + 'fields' => [ + ['handle' => 'date', 'field' => ['type' => 'date', 'time_enabled' => true]], + ['handle' => 'date_with_time', 'field' => ['type' => 'date', 'time_enabled' => true]], + ['handle' => 'date_with_time_and_seconds', 'field' => ['type' => 'date', 'time_enabled' => true, 'time_seconds_enabled' => true]], + ['handle' => 'date_with_time_and_custom_format', 'field' => ['type' => 'date', 'time_enabled' => true, 'format' => 'U']], + ['handle' => 'date_without_time', 'field' => ['type' => 'date']], + ['handle' => 'date_range', 'field' => ['type' => 'date', 'mode' => 'range']], + ], + ])->save(); + + $entry = Entry::make() + ->collection('articles') + ->date('2025-01-01-1200') + ->data([ + 'date_with_time' => '2025-01-01 12:00', + 'date_with_time_and_seconds' => '2025-01-01 12:00:15', + 'date_with_time_and_custom_format' => 1735689600, + 'date_without_time' => '2025-01-01', + 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], + ]); + + $entry->save(); + + $this->runUpdateScript(ConvertDatesToUtc::class); + + $entry->fresh(); + + $this->assertEquals('2025-01-01 17:00', $entry->date()->format('Y-m-d H:i')); + $this->assertEquals('2025-01-01 17:00', $entry->get('date_with_time')); + $this->assertEquals('2025-01-01 17:00:15', $entry->get('date_with_time_and_seconds')); + $this->assertEquals(1735689600, $entry->get('date_with_time_and_custom_format')); + $this->assertEquals('2025-01-01', $entry->get('date_without_time')); + $this->assertEquals(['start' => '2025-01-01', 'end' => '2025-01-07'], $entry->get('date_range')); + } + + // TODO: Handle date fields in Bards/Replicators/Grids/Groups + // TODO: Refactor test to use data provider + // TODO: Add tests for other content types (terms, globals, users) +} From 616c92d4244f7d05c3e3e8f14c2816fecf3fa82c Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 28 Jan 2025 17:21:16 +0000 Subject: [PATCH 02/19] Implement update script --- src/Providers/ExtensionServiceProvider.php | 1 + src/UpdateScripts/ConvertDatesToUtc.php | 113 +++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/UpdateScripts/ConvertDatesToUtc.php diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index 3712cde802..754d4823ae 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -246,6 +246,7 @@ class ExtensionServiceProvider extends ServiceProvider Updates\AddSitePermissions::class, Updates\UseClassBasedStatamicUniqueRules::class, Updates\MigrateSitesConfigToYaml::class, + Updates\ConvertDatesToUtc::class, ]; public function register() diff --git a/src/UpdateScripts/ConvertDatesToUtc.php b/src/UpdateScripts/ConvertDatesToUtc.php new file mode 100644 index 0000000000..80386483a8 --- /dev/null +++ b/src/UpdateScripts/ConvertDatesToUtc.php @@ -0,0 +1,113 @@ +isUpdatingTo('6.0.0'); + } + + public function update() + { + // TODO: Bail out early if the app's timezone is UTC (and therefore all the dates will already be in UTC) + // TODO: Improve performance (rather than collecting Repo::all() results, can we use chunking or lazy loading?) + + $this + ->getItemsContainingData() + ->filter(fn ($item) => $item instanceof EntryContract) // todo: code needs abstracting for all types of content + ->each(function ($item) { + $dateFields = $item->blueprint()->fields()->all()->filter(fn ($field) => $field->type() === 'date'); + + if ($dateFields->isEmpty()) { + return; + } + + $dateFields->each(function (Field $field) use ($item) { + if ( + $item instanceof EntryContract + && $item->collection()->dated() + && $field->handle() === 'date' + ) { + $format = $this->formatForEntryDateField($field); + + $item->date($item->date()->setTimezone('UTC')->format($format)); + + return; + } + + $value = $item->get($field->handle()); + + if (! $value) { + return; + } + + $value = $field->get('mode') === 'range' + ? $this->processRange($value, $field) + : $this->processSingle($value, $field); + + $item->set($field->handle(), $value); + }); + + if ($item->isDirty()) { + $item->saveQuietly(); + } + }); + } + + private function processRange(array $value, Field $field): array + { + return [ + 'start' => $this->processSingle($value['start'], $field), + 'end' => $this->processSingle($value['end'], $field), + ]; + } + + private function processSingle(int|string $value, Field $field): int|string + { + $value = Carbon::parse($value) + ->setTimezone('UTC') + ->format($field->get('format', $this->defaultFormat($field))); + + if (is_numeric($value)) { + $value = (int) $value; + } + + return $value; + } + + private function formatForEntryDateField(Field $field): string + { + $format = 'Y-m-d'; + + if ($field->get('time_enabled')) { + $format .= '-Hi'; + } + + if ($field->get('time_seconds_enabled')) { + $format .= 's'; + } + + return $format; + } + + private function defaultFormat(Field $field): string + { + if ($field->get('time_enabled') && $field->get('mode', 'single') === 'single') { + return $field->get('time_seconds_enabled') + ? DateFieldtype::DEFAULT_DATETIME_WITH_SECONDS_FORMAT + : DateFieldtype::DEFAULT_DATETIME_FORMAT; + } + + return DateFieldtype::DEFAULT_DATE_FORMAT; + } +} From d8452be33115bf49ab8180d4bf0cb29436069959 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 30 Jan 2025 14:51:49 +0000 Subject: [PATCH 03/19] Support nested fields in UTC update script --- src/UpdateScripts/ConvertDatesToUtc.php | 156 +++++++++++++++--- tests/UpdateScripts/ConvertDatesToUtcTest.php | 70 +++++++- 2 files changed, 201 insertions(+), 25 deletions(-) diff --git a/src/UpdateScripts/ConvertDatesToUtc.php b/src/UpdateScripts/ConvertDatesToUtc.php index 80386483a8..7dd68bcd04 100644 --- a/src/UpdateScripts/ConvertDatesToUtc.php +++ b/src/UpdateScripts/ConvertDatesToUtc.php @@ -5,8 +5,10 @@ use Carbon\Carbon; use Statamic\Contracts\Entries\Entry as EntryContract; use Statamic\Fields\Field; +use Statamic\Fields\Fields; use Statamic\Fieldtypes\Date as DateFieldtype; use Statamic\Listeners\Concerns\GetsItemsContainingData; +use Statamic\Support\Arr; class ConvertDatesToUtc extends UpdateScript { @@ -24,44 +26,152 @@ public function update() $this ->getItemsContainingData() - ->filter(fn ($item) => $item instanceof EntryContract) // todo: code needs abstracting for all types of content + // todo: code needs abstracting for all types of content + ->filter(fn ($item) => $item instanceof EntryContract) ->each(function ($item) { - $dateFields = $item->blueprint()->fields()->all()->filter(fn ($field) => $field->type() === 'date'); + /** @var Fields $fields */ + $fields = $item->blueprint()->fields(); + + $this->recursivelyUpdateFields($item, $fields); + + if ($item->isDirty()) { + $item->saveQuietly(); + } + }); + } + + private function recursivelyUpdateFields($item, Fields $fields, ?string $dottedPrefix = null): void + { + $this + ->updateDateFields($item, $fields, $dottedPrefix) + ->updateDateFieldsInGroups($item, $fields, $dottedPrefix) + ->updateDateFieldsInGrids($item, $fields, $dottedPrefix) + ->updateDateFieldsInReplicators($item, $fields, $dottedPrefix) + ->updateDateFieldsInBard($item, $fields, $dottedPrefix); + } + + private function updateDateFields($item, Fields $fields, ?string $dottedPrefix = null): self + { + $fields->all() + ->filter(fn (Field $field) => $field->type() === 'date') + ->each(function (Field $field) use ($item, $dottedPrefix) { + if ( + $item instanceof EntryContract + && $item->collection()->dated() + && empty($dottedPrefix) + && $field->handle() === 'date' + ) { + $format = $this->formatForEntryDateField($field); + + $item->date($item->date()->setTimezone('UTC')->format($format)); - if ($dateFields->isEmpty()) { return; } - $dateFields->each(function (Field $field) use ($item) { - if ( - $item instanceof EntryContract - && $item->collection()->dated() - && $field->handle() === 'date' - ) { - $format = $this->formatForEntryDateField($field); + $data = $item->data()->all(); - $item->date($item->date()->setTimezone('UTC')->format($format)); + $dottedKey = $dottedPrefix.$field->handle(); - return; - } + if (! Arr::has($data, $dottedKey)) { + return; + } + + $value = Arr::get($data, $dottedKey); + + $value = $field->get('mode') === 'range' + ? $this->processRange($value, $field) + : $this->processSingle($value, $field); - $value = $item->get($field->handle()); + Arr::set($data, $dottedKey, $value); - if (! $value) { - return; + $item->data($data); + }); + + return $this; + } + + private function updateDateFieldsInGroups($item, Fields $fields, ?string $dottedPrefix = null): self + { + $fields->all() + ->filter(fn (Field $field) => $field->type() === 'group') + ->each(function (Field $field) use ($item, $dottedPrefix) { + $dottedKey = "{$dottedPrefix}{$field->handle()}"; + + $this->updateDateFields($item, $field->fieldtype()->fields(), $dottedKey.'.'); + }); + + return $this; + } + + private function updateDateFieldsInGrids($item, Fields $fields, ?string $dottedPrefix = null): self + { + $fields->all() + ->filter(fn (Field $field) => $field->type() === 'grid') + ->each(function (Field $field) use ($item, $dottedPrefix) { + $data = $item->data(); + $dottedKey = "{$dottedPrefix}{$field->handle()}"; + + $rows = Arr::get($data, $dottedKey, []); + + collect($rows)->each(function ($set, $setKey) use ($item, $dottedKey, $field) { + $dottedPrefix = "{$dottedKey}.{$setKey}."; + $fields = Arr::get($field->config(), 'fields'); + + if ($fields) { + $this->recursivelyUpdateFields($item, new Fields($fields), $dottedPrefix); } + }); + }); - $value = $field->get('mode') === 'range' - ? $this->processRange($value, $field) - : $this->processSingle($value, $field); + return $this; + } + + private function updateDateFieldsInReplicators($item, Fields $fields, ?string $dottedPrefix = null): self + { + $fields->all() + ->filter(fn (Field $field) => $field->type() === 'replicator') + ->each(function (Field $field) use ($item, $dottedPrefix) { + $data = $item->data(); + $dottedKey = "{$dottedPrefix}{$field->handle()}"; - $item->set($field->handle(), $value); + $sets = Arr::get($data, $dottedKey); + + collect($sets)->each(function ($set, $setKey) use ($item, $dottedKey, $field) { + $dottedPrefix = "{$dottedKey}.{$setKey}."; + $setHandle = Arr::get($set, 'type'); + $fields = Arr::get($field->fieldtype()->flattenedSetsConfig(), "{$setHandle}.fields"); + + if ($setHandle && $fields) { + $this->recursivelyUpdateFields($item, new Fields($fields), $dottedPrefix); + } }); + }); - if ($item->isDirty()) { - $item->saveQuietly(); - } + return $this; + } + + private function updateDateFieldsInBard($item, Fields $fields, ?string $dottedPrefix = null): self + { + $fields->all() + ->filter(fn (Field $field) => $field->type() === 'bard') + ->each(function (Field $field) use ($item, $dottedPrefix) { + $data = $item->data(); + $dottedKey = "{$dottedPrefix}{$field->handle()}"; + + $sets = Arr::get($data, $dottedKey); + + collect($sets)->each(function ($set, $setKey) use ($item, $dottedKey, $field) { + $dottedPrefix = "{$dottedKey}.{$setKey}.attrs.values."; + $setHandle = Arr::get($set, 'attrs.values.type'); + $fields = Arr::get($field->fieldtype()->flattenedSetsConfig(), "{$setHandle}.fields"); + + if ($setHandle && $fields) { + $this->recursivelyUpdateFields($item, new Fields($fields), $dottedPrefix); + } + }); }); + + return $this; } private function processRange(array $value, Field $field): array diff --git a/tests/UpdateScripts/ConvertDatesToUtcTest.php b/tests/UpdateScripts/ConvertDatesToUtcTest.php index e9a273b315..7ce3728f75 100644 --- a/tests/UpdateScripts/ConvertDatesToUtcTest.php +++ b/tests/UpdateScripts/ConvertDatesToUtcTest.php @@ -42,6 +42,34 @@ public function it_can_convert_date_fields_in_entries() ['handle' => 'date_with_time_and_custom_format', 'field' => ['type' => 'date', 'time_enabled' => true, 'format' => 'U']], ['handle' => 'date_without_time', 'field' => ['type' => 'date']], ['handle' => 'date_range', 'field' => ['type' => 'date', 'mode' => 'range']], + ['handle' => 'group_field', 'field' => ['type' => 'group', 'fields' => [ + ['handle' => 'date_and_time', 'field' => ['type' => 'date', 'time_enabled' => true]], + ['handle' => 'date_range', 'field' => ['type' => 'date', 'mode' => 'range']], + ]]], + ['handle' => 'grid_field', 'field' => ['type' => 'grid', 'fields' => [ + ['handle' => 'date_and_time', 'field' => ['type' => 'date', 'time_enabled' => true]], + ['handle' => 'date_range', 'field' => ['type' => 'date', 'mode' => 'range']], + ['handle' => 'second_grid_field', 'field' => ['type' => 'grid', 'fields' => [ + ['handle' => 'date_and_time', 'field' => ['type' => 'date', 'time_enabled' => true]], + ['handle' => 'date_range', 'field' => ['type' => 'date', 'mode' => 'range']], + ]]], + ]]], + ['handle' => 'replicator_field', 'field' => ['type' => 'replicator', 'sets' => [ + 'set_group' => ['sets' => [ + 'set_name' => ['fields' => [ + ['handle' => 'date_and_time', 'field' => ['type' => 'date', 'time_enabled' => true]], + ['handle' => 'date_range', 'field' => ['type' => 'date', 'mode' => 'range']], + ]], + ]], + ]]], + ['handle' => 'bard_field', 'field' => ['type' => 'bard', 'sets' => [ + 'set_group' => ['sets' => [ + 'set_name' => ['fields' => [ + ['handle' => 'date_and_time', 'field' => ['type' => 'date', 'time_enabled' => true]], + ['handle' => 'date_range', 'field' => ['type' => 'date', 'mode' => 'range']], + ]], + ]], + ]]], ], ])->save(); @@ -54,6 +82,22 @@ public function it_can_convert_date_fields_in_entries() 'date_with_time_and_custom_format' => 1735689600, 'date_without_time' => '2025-01-01', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], + 'group_field' => ['date_and_time' => '2025-01-01 12:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']], + 'grid_field' => [ + ['date_and_time' => '2025-01-01 12:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], 'second_grid_field' => [ + ['date_and_time' => '2025-01-01 12:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']], + ]], + ], + 'replicator_field' => [ + ['type' => 'set_name', 'date_and_time' => '2025-01-01 12:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']], + ], + 'bard_field' => [ + ['type' => 'set', 'attrs' => ['id' => 'abc', 'values' => [ + 'type' => 'set_name', + 'date_and_time' => '2025-01-01 12:00', + 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], + ]]], + ], ]); $entry->save(); @@ -67,10 +111,32 @@ public function it_can_convert_date_fields_in_entries() $this->assertEquals('2025-01-01 17:00:15', $entry->get('date_with_time_and_seconds')); $this->assertEquals(1735689600, $entry->get('date_with_time_and_custom_format')); $this->assertEquals('2025-01-01', $entry->get('date_without_time')); - $this->assertEquals(['start' => '2025-01-01', 'end' => '2025-01-07'], $entry->get('date_range')); + + $this->assertEquals([ + 'date_and_time' => '2025-01-01 17:00', + 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], + ], $entry->get('group_field')); + + $this->assertEquals([ + ['date_and_time' => '2025-01-01 17:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], 'second_grid_field' => [ + ['date_and_time' => '2025-01-01 17:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']], + ]], + ], $entry->get('grid_field')); + + $this->assertEquals([ + ['type' => 'set_name', 'date_and_time' => '2025-01-01 17:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']], + ], $entry->get('replicator_field')); + + $this->assertEquals([ + ['type' => 'set', 'attrs' => ['id' => 'abc', 'values' => [ + 'type' => 'set_name', + 'date_and_time' => '2025-01-01 17:00', + 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], + ]]], + ], $entry->get('bard_field')); } - // TODO: Handle date fields in Bards/Replicators/Grids/Groups + // todo: ensure fields inside fieldsets are updated // TODO: Refactor test to use data provider // TODO: Add tests for other content types (terms, globals, users) } From bee4d6509aab3ee38ff7144d6a4c2d7021299694 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 30 Jan 2025 15:43:46 +0000 Subject: [PATCH 04/19] Ensure fields inside fieldsets are handled properly --- tests/UpdateScripts/ConvertDatesToUtcTest.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/UpdateScripts/ConvertDatesToUtcTest.php b/tests/UpdateScripts/ConvertDatesToUtcTest.php index 7ce3728f75..a1f6419bf4 100644 --- a/tests/UpdateScripts/ConvertDatesToUtcTest.php +++ b/tests/UpdateScripts/ConvertDatesToUtcTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Collection; use Statamic\Facades\Entry; +use Statamic\Facades\Fieldset; use Statamic\UpdateScripts\ConvertDatesToUtc; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -34,6 +35,10 @@ public function it_can_convert_date_fields_in_entries() $collection = tap(Collection::make('articles')->dated(true))->save(); + Fieldset::make('date_fieldset')->setContents(['fields' => [ + ['handle' => 'fieldset_date', 'field' => ['type' => 'date', 'time_enabled' => true]], + ]])->save(); + $collection->entryBlueprint()->setContents([ 'fields' => [ ['handle' => 'date', 'field' => ['type' => 'date', 'time_enabled' => true]], @@ -70,6 +75,9 @@ public function it_can_convert_date_fields_in_entries() ]], ]], ]]], + ['import' => 'date_fieldset'], + ['import' => 'date_fieldset', 'prefix' => 'prefixed_'], + ['handle' => 'date_fieldset_single_field', 'field' => 'date_fieldset.fieldset_date'], ], ])->save(); @@ -98,6 +106,9 @@ public function it_can_convert_date_fields_in_entries() 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], ]]], ], + 'fieldset_date' => '2025-01-01 12:00', + 'prefixed_fieldset_date' => '2025-01-01 12:00', + 'date_fieldset_single_field' => '2025-01-01 12:00', ]); $entry->save(); @@ -134,9 +145,12 @@ public function it_can_convert_date_fields_in_entries() 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], ]]], ], $entry->get('bard_field')); + + $this->assertEquals('2025-01-01 17:00', $entry->get('fieldset_date')); + $this->assertEquals('2025-01-01 17:00', $entry->get('prefixed_fieldset_date')); + $this->assertEquals('2025-01-01 17:00', $entry->get('date_fieldset_single_field')); } - // todo: ensure fields inside fieldsets are updated // TODO: Refactor test to use data provider // TODO: Add tests for other content types (terms, globals, users) } From 2b61ad84178ff4ff2b8b7cefec9417f290aad85b Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 30 Jan 2025 19:09:35 +0000 Subject: [PATCH 05/19] Use data provider in tests --- tests/UpdateScripts/ConvertDatesToUtcTest.php | 199 ++++++++++-------- 1 file changed, 109 insertions(+), 90 deletions(-) diff --git a/tests/UpdateScripts/ConvertDatesToUtcTest.php b/tests/UpdateScripts/ConvertDatesToUtcTest.php index a1f6419bf4..06cd7c4a33 100644 --- a/tests/UpdateScripts/ConvertDatesToUtcTest.php +++ b/tests/UpdateScripts/ConvertDatesToUtcTest.php @@ -2,6 +2,7 @@ namespace Tests\UpdateScripts; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Collection; use Statamic\Facades\Entry; @@ -15,6 +16,15 @@ class ConvertDatesToUtcTest extends TestCase { use PreventSavingStacheItemsToDisk, RunsUpdateScripts; + public function setUp(): void + { + parent::setUp(); + + Fieldset::make('date_fieldset')->setContents(['fields' => [ + ['handle' => 'fieldset_date', 'field' => ['type' => 'date', 'time_enabled' => true]], + ]])->save(); + } + #[Test] public function it_is_registered() { @@ -28,37 +38,89 @@ public function is_skipped_when_application_timezone_is_utc() } #[Test] - public function it_can_convert_date_fields_in_entries() + #[DataProvider('dateFieldsProvider')] + public function it_can_convert_date_fields_in_entries(string $fieldHandle, array $field, $original, $expected) { config()->set('app.timezone', 'America/New_York'); // -05:00 date_default_timezone_set('America/New_York'); - $collection = tap(Collection::make('articles')->dated(true))->save(); + $collection = tap(Collection::make('articles'))->save(); - Fieldset::make('date_fieldset')->setContents(['fields' => [ - ['handle' => 'fieldset_date', 'field' => ['type' => 'date', 'time_enabled' => true]], - ]])->save(); + $collection->entryBlueprint()->setContents(['fields' => [$field]])->save(); + + $entry = Entry::make()->collection('articles')->data([$fieldHandle => $original]); + $entry->save(); + + $this->runUpdateScript(ConvertDatesToUtc::class); - $collection->entryBlueprint()->setContents([ - 'fields' => [ - ['handle' => 'date', 'field' => ['type' => 'date', 'time_enabled' => true]], - ['handle' => 'date_with_time', 'field' => ['type' => 'date', 'time_enabled' => true]], - ['handle' => 'date_with_time_and_seconds', 'field' => ['type' => 'date', 'time_enabled' => true, 'time_seconds_enabled' => true]], - ['handle' => 'date_with_time_and_custom_format', 'field' => ['type' => 'date', 'time_enabled' => true, 'format' => 'U']], - ['handle' => 'date_without_time', 'field' => ['type' => 'date']], - ['handle' => 'date_range', 'field' => ['type' => 'date', 'mode' => 'range']], + $this->assertEquals($expected, $entry->fresh()->get($fieldHandle)); + } + + public static function dateFieldsProvider(): array + { + return [ + 'Date field' => [ + 'date_field', + ['handle' => 'date_field', 'field' => ['type' => 'date']], + '2025-01-01', + '2025-01-01', + ], + 'Date field with time enabled' => [ + 'date_field', + ['handle' => 'date_field', 'field' => ['type' => 'date', 'time_enabled' => true]], + '2025-01-01 12:00', + '2025-01-01 17:00', + ], + 'Date field with time and seconds enabled' => [ + 'date_field', + ['handle' => 'date_field', 'field' => ['type' => 'date', 'time_enabled' => true, 'time_seconds_enabled' => true]], + '2025-01-01 12:00:15', + '2025-01-01 17:00:15', + ], + 'Date field with time enabled, and a custom format' => [ + 'date_field', + ['handle' => 'date_field', 'field' => ['type' => 'date', 'time_enabled' => true, 'format' => 'U']], + 1735689600, + 1735689600, + ], + 'Date range' => [ + 'date_field', + ['handle' => 'date_field', 'field' => ['type' => 'date', 'mode' => 'range']], + ['start' => '2025-01-01', 'end' => '2025-01-07'], + ['start' => '2025-01-01', 'end' => '2025-01-07'], + ], + 'Imported date field' => [ + 'fieldset_date', + ['import' => 'date_fieldset'], + '2025-01-01 12:00', + '2025-01-01 17:00', + ], + 'Group field with nested date fields' => [ + 'group_field', ['handle' => 'group_field', 'field' => ['type' => 'group', 'fields' => [ ['handle' => 'date_and_time', 'field' => ['type' => 'date', 'time_enabled' => true]], ['handle' => 'date_range', 'field' => ['type' => 'date', 'mode' => 'range']], ]]], + [ + 'date_and_time' => '2025-01-01 12:00', + 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], + ], + [ + 'date_and_time' => '2025-01-01 17:00', + 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], + ], + ], + 'Grid field with nested date fields' => [ + 'grid_field', ['handle' => 'grid_field', 'field' => ['type' => 'grid', 'fields' => [ ['handle' => 'date_and_time', 'field' => ['type' => 'date', 'time_enabled' => true]], ['handle' => 'date_range', 'field' => ['type' => 'date', 'mode' => 'range']], - ['handle' => 'second_grid_field', 'field' => ['type' => 'grid', 'fields' => [ - ['handle' => 'date_and_time', 'field' => ['type' => 'date', 'time_enabled' => true]], - ['handle' => 'date_range', 'field' => ['type' => 'date', 'mode' => 'range']], - ]]], ]]], + [['date_and_time' => '2025-01-01 12:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']]], + [['date_and_time' => '2025-01-01 17:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']]], + ], + 'Replicator field with nested date fields' => [ + 'replicator_field', ['handle' => 'replicator_field', 'field' => ['type' => 'replicator', 'sets' => [ 'set_group' => ['sets' => [ 'set_name' => ['fields' => [ @@ -67,6 +129,11 @@ public function it_can_convert_date_fields_in_entries() ]], ]], ]]], + [['type' => 'set_name', 'date_and_time' => '2025-01-01 12:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']]], + [['type' => 'set_name', 'date_and_time' => '2025-01-01 17:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']]], + ], + 'Bard field with nested date fields' => [ + 'bard_field', ['handle' => 'bard_field', 'field' => ['type' => 'bard', 'sets' => [ 'set_group' => ['sets' => [ 'set_name' => ['fields' => [ @@ -75,82 +142,34 @@ public function it_can_convert_date_fields_in_entries() ]], ]], ]]], - ['import' => 'date_fieldset'], - ['import' => 'date_fieldset', 'prefix' => 'prefixed_'], - ['handle' => 'date_fieldset_single_field', 'field' => 'date_fieldset.fieldset_date'], + [['type' => 'set', 'attrs' => ['id' => 'abc', 'values' => [ + 'type' => 'set_name', + 'date_and_time' => '2025-01-01 12:00', + 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], + ]]]], + [['type' => 'set', 'attrs' => ['id' => 'abc', 'values' => [ + 'type' => 'set_name', + 'date_and_time' => '2025-01-01 17:00', + 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], + ]]]], ], - ])->save(); - - $entry = Entry::make() - ->collection('articles') - ->date('2025-01-01-1200') - ->data([ - 'date_with_time' => '2025-01-01 12:00', - 'date_with_time_and_seconds' => '2025-01-01 12:00:15', - 'date_with_time_and_custom_format' => 1735689600, - 'date_without_time' => '2025-01-01', - 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], - 'group_field' => ['date_and_time' => '2025-01-01 12:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']], - 'grid_field' => [ - ['date_and_time' => '2025-01-01 12:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], 'second_grid_field' => [ - ['date_and_time' => '2025-01-01 12:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']], - ]], - ], - 'replicator_field' => [ - ['type' => 'set_name', 'date_and_time' => '2025-01-01 12:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']], - ], - 'bard_field' => [ - ['type' => 'set', 'attrs' => ['id' => 'abc', 'values' => [ - 'type' => 'set_name', - 'date_and_time' => '2025-01-01 12:00', - 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], + 'Deeply nested date fields' => [ + 'deeply_nested_date_fields', + ['handle' => 'deeply_nested_date_fields', 'field' => ['type' => 'grid', 'fields' => [ + ['handle' => 'nested_group', 'field' => ['type' => 'group', 'fields' => [ + ['handle' => 'date_and_time', 'field' => ['type' => 'date', 'time_enabled' => true]], + ['handle' => 'date_range', 'field' => ['type' => 'date', 'mode' => 'range']], ]]], - ], - 'fieldset_date' => '2025-01-01 12:00', - 'prefixed_fieldset_date' => '2025-01-01 12:00', - 'date_fieldset_single_field' => '2025-01-01 12:00', - ]); - - $entry->save(); - - $this->runUpdateScript(ConvertDatesToUtc::class); - - $entry->fresh(); - - $this->assertEquals('2025-01-01 17:00', $entry->date()->format('Y-m-d H:i')); - $this->assertEquals('2025-01-01 17:00', $entry->get('date_with_time')); - $this->assertEquals('2025-01-01 17:00:15', $entry->get('date_with_time_and_seconds')); - $this->assertEquals(1735689600, $entry->get('date_with_time_and_custom_format')); - $this->assertEquals('2025-01-01', $entry->get('date_without_time')); - - $this->assertEquals([ - 'date_and_time' => '2025-01-01 17:00', - 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], - ], $entry->get('group_field')); - - $this->assertEquals([ - ['date_and_time' => '2025-01-01 17:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], 'second_grid_field' => [ - ['date_and_time' => '2025-01-01 17:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']], - ]], - ], $entry->get('grid_field')); - - $this->assertEquals([ - ['type' => 'set_name', 'date_and_time' => '2025-01-01 17:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07']], - ], $entry->get('replicator_field')); - - $this->assertEquals([ - ['type' => 'set', 'attrs' => ['id' => 'abc', 'values' => [ - 'type' => 'set_name', - 'date_and_time' => '2025-01-01 17:00', - 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], - ]]], - ], $entry->get('bard_field')); - - $this->assertEquals('2025-01-01 17:00', $entry->get('fieldset_date')); - $this->assertEquals('2025-01-01 17:00', $entry->get('prefixed_fieldset_date')); - $this->assertEquals('2025-01-01 17:00', $entry->get('date_fieldset_single_field')); + ]]], + [['nested_group' => [ + 'date_and_time' => '2025-01-01 12:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], + ]]], + [['nested_group' => [ + 'date_and_time' => '2025-01-01 17:00', 'date_range' => ['start' => '2025-01-01', 'end' => '2025-01-07'], + ]]], + ], + ]; } - // TODO: Refactor test to use data provider // TODO: Add tests for other content types (terms, globals, users) } From e8cb35f80889869607ebb71646e3df63920551fa Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 30 Jan 2025 19:10:12 +0000 Subject: [PATCH 06/19] Remove condition I added for troubleshooting --- src/UpdateScripts/ConvertDatesToUtc.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/UpdateScripts/ConvertDatesToUtc.php b/src/UpdateScripts/ConvertDatesToUtc.php index 7dd68bcd04..e560b74e98 100644 --- a/src/UpdateScripts/ConvertDatesToUtc.php +++ b/src/UpdateScripts/ConvertDatesToUtc.php @@ -26,8 +26,6 @@ public function update() $this ->getItemsContainingData() - // todo: code needs abstracting for all types of content - ->filter(fn ($item) => $item instanceof EntryContract) ->each(function ($item) { /** @var Fields $fields */ $fields = $item->blueprint()->fields(); From d7d2595e0848701b2fdcb2bea76d3b637400a15f Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 30 Jan 2025 19:13:39 +0000 Subject: [PATCH 07/19] Bail out early when the app's timezone is already UTC In that case, we have nothing to do here. --- src/UpdateScripts/ConvertDatesToUtc.php | 5 ++++- tests/UpdateScripts/ConvertDatesToUtcTest.php | 13 ++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/UpdateScripts/ConvertDatesToUtc.php b/src/UpdateScripts/ConvertDatesToUtc.php index e560b74e98..bcac58fda0 100644 --- a/src/UpdateScripts/ConvertDatesToUtc.php +++ b/src/UpdateScripts/ConvertDatesToUtc.php @@ -21,7 +21,10 @@ public function shouldUpdate($newVersion, $oldVersion) public function update() { - // TODO: Bail out early if the app's timezone is UTC (and therefore all the dates will already be in UTC) + if (config('app.timezone') === 'UTC') { + return; + } + // TODO: Improve performance (rather than collecting Repo::all() results, can we use chunking or lazy loading?) $this diff --git a/tests/UpdateScripts/ConvertDatesToUtcTest.php b/tests/UpdateScripts/ConvertDatesToUtcTest.php index 06cd7c4a33..30cd9d17c0 100644 --- a/tests/UpdateScripts/ConvertDatesToUtcTest.php +++ b/tests/UpdateScripts/ConvertDatesToUtcTest.php @@ -7,6 +7,9 @@ use Statamic\Facades\Collection; use Statamic\Facades\Entry; use Statamic\Facades\Fieldset; +use Statamic\Facades\GlobalSet; +use Statamic\Facades\Term; +use Statamic\Facades\User; use Statamic\UpdateScripts\ConvertDatesToUtc; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -34,7 +37,15 @@ public function it_is_registered() #[Test] public function is_skipped_when_application_timezone_is_utc() { - $this->markTestIncomplete(); + Entry::shouldReceive('all')->never(); + Term::shouldReceive('all')->never(); + GlobalSet::shouldReceive('all')->never(); + User::shouldReceive('all')->never(); + + config()->set('app.timezone', 'UTC'); + date_default_timezone_set('UTC'); + + $this->runUpdateScript(ConvertDatesToUtc::class); } #[Test] From a92bc0fb350f5eaab4e94257fede78d17143ae87 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 30 Jan 2025 19:16:54 +0000 Subject: [PATCH 08/19] Ensure entry date field is updated correctly. --- tests/UpdateScripts/ConvertDatesToUtcTest.php | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/UpdateScripts/ConvertDatesToUtcTest.php b/tests/UpdateScripts/ConvertDatesToUtcTest.php index 30cd9d17c0..e7526ad570 100644 --- a/tests/UpdateScripts/ConvertDatesToUtcTest.php +++ b/tests/UpdateScripts/ConvertDatesToUtcTest.php @@ -50,7 +50,7 @@ public function is_skipped_when_application_timezone_is_utc() #[Test] #[DataProvider('dateFieldsProvider')] - public function it_can_convert_date_fields_in_entries(string $fieldHandle, array $field, $original, $expected) + public function it_converts_date_fields_in_entries(string $fieldHandle, array $field, $original, $expected) { config()->set('app.timezone', 'America/New_York'); // -05:00 date_default_timezone_set('America/New_York'); @@ -67,6 +67,30 @@ public function it_can_convert_date_fields_in_entries(string $fieldHandle, array $this->assertEquals($expected, $entry->fresh()->get($fieldHandle)); } + #[Test] + public function it_converts_entry_date_field_in_entries() + { + config()->set('app.timezone', 'America/New_York'); // -05:00 + date_default_timezone_set('America/New_York'); + + $collection = tap(Collection::make('articles')->dated(true))->save(); + + $collection->entryBlueprint()->setContents([ + 'fields' => [ + ['handle' => 'date', 'field' => ['type' => 'date', 'time_enabled' => true]], + ], + ])->save(); + + $entry = Entry::make()->collection('articles')->date('2025-01-01-1200'); + $entry->save(); + + $this->runUpdateScript(ConvertDatesToUtc::class); + + $entry->fresh(); + + $this->assertEquals('2025-01-01 17:00', $entry->date()->format('Y-m-d H:i')); + } + public static function dateFieldsProvider(): array { return [ From cd82107721ac038c7bb0e021861c400611360c91 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 30 Jan 2025 19:38:56 +0000 Subject: [PATCH 09/19] wip --- src/UpdateScripts/ConvertDatesToUtc.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/UpdateScripts/ConvertDatesToUtc.php b/src/UpdateScripts/ConvertDatesToUtc.php index bcac58fda0..cf307edecd 100644 --- a/src/UpdateScripts/ConvertDatesToUtc.php +++ b/src/UpdateScripts/ConvertDatesToUtc.php @@ -25,8 +25,6 @@ public function update() return; } - // TODO: Improve performance (rather than collecting Repo::all() results, can we use chunking or lazy loading?) - $this ->getItemsContainingData() ->each(function ($item) { From a403b8adf78924f6291f9ef5b03fbc56b1f0d803 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 30 Jan 2025 19:41:39 +0000 Subject: [PATCH 10/19] Add test to cover updating users too --- tests/UpdateScripts/ConvertDatesToUtcTest.php | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/UpdateScripts/ConvertDatesToUtcTest.php b/tests/UpdateScripts/ConvertDatesToUtcTest.php index e7526ad570..7f73156c53 100644 --- a/tests/UpdateScripts/ConvertDatesToUtcTest.php +++ b/tests/UpdateScripts/ConvertDatesToUtcTest.php @@ -91,6 +91,37 @@ public function it_converts_entry_date_field_in_entries() $this->assertEquals('2025-01-01 17:00', $entry->date()->format('Y-m-d H:i')); } + #[Test] + #[DataProvider('dateFieldsProvider')] + public function it_converts_entry_date_field_in_terms() + { + $this->markTestIncomplete(); + } + + #[Test] + #[DataProvider('dateFieldsProvider')] + public function it_converts_entry_date_field_in_globals() + { + $this->markTestIncomplete(); + } + + #[Test] + #[DataProvider('dateFieldsProvider')] + public function it_converts_date_fields_in_users(string $fieldHandle, array $field, $original, $expected) + { + config()->set('app.timezone', 'America/New_York'); // -05:00 + date_default_timezone_set('America/New_York'); + + User::blueprint()->setContents(['fields' => [$field]])->save(); + + $user = User::make()->data([$fieldHandle => $original]); + $user->save(); + + $this->runUpdateScript(ConvertDatesToUtc::class); + + $this->assertEquals($expected, $user->fresh()->get($fieldHandle)); + } + public static function dateFieldsProvider(): array { return [ @@ -205,6 +236,4 @@ public static function dateFieldsProvider(): array ], ]; } - - // TODO: Add tests for other content types (terms, globals, users) } From 69d0d39b00e5462a0e743ed9946835774c0ac919 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Fri, 31 Jan 2025 11:06:10 +0000 Subject: [PATCH 11/19] Add tests for terms & globals too --- src/UpdateScripts/ConvertDatesToUtc.php | 4 +- tests/UpdateScripts/ConvertDatesToUtcTest.php | 37 ++++++++++++++++--- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/UpdateScripts/ConvertDatesToUtc.php b/src/UpdateScripts/ConvertDatesToUtc.php index cf307edecd..594540b867 100644 --- a/src/UpdateScripts/ConvertDatesToUtc.php +++ b/src/UpdateScripts/ConvertDatesToUtc.php @@ -33,9 +33,7 @@ public function update() $this->recursivelyUpdateFields($item, $fields); - if ($item->isDirty()) { - $item->saveQuietly(); - } + $item->saveQuietly(); }); } diff --git a/tests/UpdateScripts/ConvertDatesToUtcTest.php b/tests/UpdateScripts/ConvertDatesToUtcTest.php index 7f73156c53..9801e30c3c 100644 --- a/tests/UpdateScripts/ConvertDatesToUtcTest.php +++ b/tests/UpdateScripts/ConvertDatesToUtcTest.php @@ -4,10 +4,12 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Facades\Blueprint; use Statamic\Facades\Collection; use Statamic\Facades\Entry; use Statamic\Facades\Fieldset; use Statamic\Facades\GlobalSet; +use Statamic\Facades\Taxonomy; use Statamic\Facades\Term; use Statamic\Facades\User; use Statamic\UpdateScripts\ConvertDatesToUtc; @@ -56,7 +58,6 @@ public function it_converts_date_fields_in_entries(string $fieldHandle, array $f date_default_timezone_set('America/New_York'); $collection = tap(Collection::make('articles'))->save(); - $collection->entryBlueprint()->setContents(['fields' => [$field]])->save(); $entry = Entry::make()->collection('articles')->data([$fieldHandle => $original]); @@ -93,16 +94,42 @@ public function it_converts_entry_date_field_in_entries() #[Test] #[DataProvider('dateFieldsProvider')] - public function it_converts_entry_date_field_in_terms() + public function it_converts_date_fields_in_terms(string $fieldHandle, array $field, $original, $expected) { - $this->markTestIncomplete(); + config()->set('app.timezone', 'America/New_York'); // -05:00 + date_default_timezone_set('America/New_York'); + + $taxonomy = tap(Taxonomy::make('tags'))->save(); + $taxonomy->termBlueprint()->setContents(['fields' => [$field]])->save(); + + $term = Term::make()->taxonomy('tags')->data([$fieldHandle => $original]); + $term->save(); + + $this->runUpdateScript(ConvertDatesToUtc::class); + + $this->assertEquals($expected, $term->fresh()->get($fieldHandle)); } #[Test] #[DataProvider('dateFieldsProvider')] - public function it_converts_entry_date_field_in_globals() + public function it_converts_date_fields_in_globals(string $fieldHandle, array $field, $original, $expected) { - $this->markTestIncomplete(); + config()->set('app.timezone', 'America/New_York'); // -05:00 + date_default_timezone_set('America/New_York'); + + $globalSet = tap(GlobalSet::make('settings'))->save(); + $globalSet->addLocalization( + $globalSet->makeLocalization('en')->data([$fieldHandle => $original]) + ); + $globalSet->save(); + + Blueprint::make('settings')->setNamespace('globals')->setContents(['fields' => [$field]])->save(); + + $this->runUpdateScript(ConvertDatesToUtc::class); + + $globalSet = GlobalSet::find('settings'); + + $this->assertEquals($expected, $globalSet->inDefaultSite()->get($fieldHandle)); } #[Test] From d3208529bbcedd1653235ea3f1ed3738e7d6629f Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Fri, 31 Jan 2025 11:47:15 +0000 Subject: [PATCH 12/19] Ensure entry dates are converted to UTC before saving --- src/Entries/Entry.php | 7 +++++-- tests/Data/Entries/EntryTest.php | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index 06a18e7c0e..25280fa943 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -591,10 +591,13 @@ public function date($date = null) } if (strlen($date) === 15) { - return Carbon::createFromFormat('Y-m-d-Hi', $date)->startOfMinute(); + return Carbon::createFromFormat('Y-m-d-Hi', $date) + ->setTimezone('UTC') + ->startOfMinute(); } - return Carbon::createFromFormat('Y-m-d-His', $date); + return Carbon::createFromFormat('Y-m-d-His', $date) + ->setTimezone('UTC'); }) ->args(func_get_args()); } diff --git a/tests/Data/Entries/EntryTest.php b/tests/Data/Entries/EntryTest.php index f99532f921..43e54dc9ff 100644 --- a/tests/Data/Entries/EntryTest.php +++ b/tests/Data/Entries/EntryTest.php @@ -1047,6 +1047,28 @@ public function setting_date_on_entry_that_doesnt_have_a_collection_set_throws_e (new Entry)->date('2023-04-19'); } + #[Test] + public function setting_date_on_entry_converts_it_to_utc() + { + config()->set('app.timezone', 'America/New_York'); // -05:00 + date_default_timezone_set('America/New_York'); + + $collection = tap(Collection::make('dated')->dated(true))->save(); + + $collection->entryBlueprint()->setContents(['fields' => [ + ['handle' => 'date', 'field' => ['type' => 'date', 'time_enabled' => true, 'time_seconds_enabled' => true]], + ]]); + + $entry = (new Entry)->collection('dated')->date('2025-01-01'); + $this->assertEquals('2025-01-01', $entry->date()->format('Y-m-d')); + + $entry = (new Entry)->collection('dated')->date('2025-01-01-1234'); + $this->assertEquals('2025-01-01 17:34', $entry->date()->format('Y-m-d H:i')); + + $entry = (new Entry)->collection('dated')->date('2025-01-01-123456'); + $this->assertEquals('2025-01-01 17:34:56', $entry->date()->format('Y-m-d H:i:s')); + } + #[Test] public function it_falls_back_to_the_origin_for_the_date() { From a133e5107843a2997ad46e52d4f092ed649a41d6 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Fri, 31 Jan 2025 12:03:23 +0000 Subject: [PATCH 13/19] Augment dates using `display_timezone` config --- config/system.php | 15 ++++++++++++++ src/Fieldtypes/Date.php | 7 ++++--- tests/Fieldtypes/DateTest.php | 38 +++++++++++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/config/system.php b/config/system.php index fc04ee4b02..1e0485ddad 100644 --- a/config/system.php +++ b/config/system.php @@ -74,6 +74,21 @@ 'date_format' => 'F jS, Y', + /* + |-------------------------------------------------------------------------- + | Display Timezone + |-------------------------------------------------------------------------- + | + | Dates and times are stored in UTC. This setting allows you to determine which + | timezone dates and times are displayed in. For a full list of supported timezones, + | please see the PHP documentation. + | + | https://www.php.net/manual/en/timezones.php + | + */ + + 'display_timezone' => 'UTC', + /* |-------------------------------------------------------------------------- | Default Character Set diff --git a/src/Fieldtypes/Date.php b/src/Fieldtypes/Date.php index d9aba7a16c..49a5b892db 100644 --- a/src/Fieldtypes/Date.php +++ b/src/Fieldtypes/Date.php @@ -311,7 +311,7 @@ public function augment($value) } if ($value instanceof Carbon) { - return $value; + return $value->setTimezone(config('statamic.system.display_timezone')); } if ($this->config('mode') === 'range') { @@ -321,7 +321,8 @@ public function augment($value) ]; } - $date = $this->parseSaved($value); + $date = $this->parseSaved($value) + ->setTimezone(config('statamic.system.display_timezone')); if (! $this->config('time_enabled')) { $date->startOfDay(); @@ -349,7 +350,7 @@ public function toQueryableValue($value) private function parseSaved($value) { try { - return Carbon::createFromFormat($this->saveFormat(), $value); + return Carbon::createFromFormat($this->saveFormat(), $value, 'UTC'); } catch (InvalidFormatException|InvalidArgumentException $e) { return Carbon::parse($value); } diff --git a/tests/Fieldtypes/DateTest.php b/tests/Fieldtypes/DateTest.php index 7d6f358bc0..703f55ae87 100644 --- a/tests/Fieldtypes/DateTest.php +++ b/tests/Fieldtypes/DateTest.php @@ -26,8 +26,10 @@ public function setUp(): void #[Test] #[DataProvider('augmentProvider')] - public function it_augments($config, $value, $expected) + public function it_augments(string $displayTimezone, array $config, string $value, string $expected) { + config()->set('statamic.system.display_timezone', $displayTimezone); + $augmented = $this->fieldtype($config)->augment($value); $this->assertInstanceOf(Carbon::class, $augmented); @@ -38,35 +40,52 @@ public static function augmentProvider() { return [ 'date' => [ + 'UTC', [], '2012-01-04', '2012 Jan 04 00:00:00', ], 'date with custom format' => [ + 'UTC', ['format' => 'Y--m--d'], '2012--01--04', '2012 Jan 04 00:00:00', ], + 'date with display timezone' => [ + 'America/New_York', + [], + '2012-01-04', + '2012 Jan 04 00:00:00', + ], // The time and seconds configs are important, otherwise - // when when parsing dates without times, the time would inherit from "now". + // when parsing dates without times, the time would inherit from "now". // We need to rely on the configs to know when or when not to reset the time. 'date with time' => [ + 'UTC', ['time_enabled' => true], '2012-01-04 15:32', '2012 Jan 04 15:32:00', ], 'date with time but seconds disabled' => [ + 'UTC', ['time_enabled' => true], '2012-01-04 15:32:54', '2012 Jan 04 15:32:00', ], 'date with time and seconds' => [ + 'UTC', ['time_enabled' => true, 'time_seconds_enabled' => true], '2012-01-04 15:32:54', '2012 Jan 04 15:32:54', ], + 'date with time and display timezone' => [ + 'America/New_York', + ['time_enabled' => true], + '2012-01-04 15:32', + '2012 Jan 04 10:32:00', // -5 hours + ], ]; } @@ -89,6 +108,21 @@ public function it_augments_a_carbon_instance() $this->assertSame($instance, $augmented); } + #[Test] + public function it_augments_a_carbon_instance_using_display_timezone_config() + { + // Could happen if you are using the date fieldtype to augment a manually provided value. + + config()->set('statamic.system.display_timezone', 'America/New_York'); // -5 hours + + $instance = new Carbon('2025-01-01 12:00:00'); + $augmented = $this->fieldtype()->augment($instance); + + $this->assertInstanceOf(Carbon::class, $augmented); + $this->assertEquals('America/New_York', $augmented->getTimezone()->getName()); + $this->assertEquals(7, $instance->hour); // 12pm in UTC is 7am in New York + } + #[Test] public function it_augments_a_range() { From 0cab1716ecf3307e657997368014f8305208f626 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Fri, 31 Jan 2025 12:09:25 +0000 Subject: [PATCH 14/19] Ensure `now` cascade variable uses the display timezone --- src/View/Cascade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/View/Cascade.php b/src/View/Cascade.php index 85758fc158..1da2144936 100644 --- a/src/View/Cascade.php +++ b/src/View/Cascade.php @@ -188,7 +188,7 @@ private function contextualVariables() 'current_user' => User::current(), // Date - 'current_date' => $now = now(), + 'current_date' => $now = now(tz: config('statamic.system.display_timezone')), 'now' => $now, 'today' => $now, From 8a0ea555b96e8cfcb6a5444dc42356b711b6a8a4 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Fri, 31 Jan 2025 12:47:04 +0000 Subject: [PATCH 15/19] `Entry::date()` handles the conversion, so we don't need to handle it here too. --- src/UpdateScripts/ConvertDatesToUtc.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/UpdateScripts/ConvertDatesToUtc.php b/src/UpdateScripts/ConvertDatesToUtc.php index 594540b867..ee2d48e8df 100644 --- a/src/UpdateScripts/ConvertDatesToUtc.php +++ b/src/UpdateScripts/ConvertDatesToUtc.php @@ -58,9 +58,7 @@ private function updateDateFields($item, Fields $fields, ?string $dottedPrefix = && empty($dottedPrefix) && $field->handle() === 'date' ) { - $format = $this->formatForEntryDateField($field); - - $item->date($item->date()->setTimezone('UTC')->format($format)); + $item->date($item->date()); return; } From baff33de5955aa54c859172bc70fc30343a2ec62 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 6 Feb 2025 17:45:15 +0000 Subject: [PATCH 16/19] Ensure GraphQL always returns dates in UTC. --- tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php b/tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php index 4cf18632ce..3d7ecec208 100644 --- a/tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php +++ b/tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php @@ -21,6 +21,10 @@ public function setUp(): void #[Test] public function it_gets_dates() { + // Set the timezone. We want to ensure the date is always returned in UTC. + config()->set('app.timezone', 'America/New_York'); // -05:00 + date_default_timezone_set('America/New_York'); + // Set the to string format so can see it uses that rather than a coincidence. // But reset it afterwards. $originalFormat = Carbon::getToStringFormat(); From f34c61d619597a35ee4f9cf0ff02393f394b71ce Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 11 Feb 2025 17:15:56 +0000 Subject: [PATCH 17/19] Revert "Ensure `now` cascade variable uses the display timezone" This reverts commit 0cab1716ecf3307e657997368014f8305208f626. --- src/View/Cascade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/View/Cascade.php b/src/View/Cascade.php index 1da2144936..85758fc158 100644 --- a/src/View/Cascade.php +++ b/src/View/Cascade.php @@ -188,7 +188,7 @@ private function contextualVariables() 'current_user' => User::current(), // Date - 'current_date' => $now = now(tz: config('statamic.system.display_timezone')), + 'current_date' => $now = now(), 'now' => $now, 'today' => $now, From 74893d632f0d49f095ec5bf605dacf8f7959de4e Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 11 Feb 2025 17:16:25 +0000 Subject: [PATCH 18/19] Revert "Augment dates using `display_timezone` config" This reverts commit a133e5107843a2997ad46e52d4f092ed649a41d6. --- config/system.php | 15 -------------- src/Fieldtypes/Date.php | 7 +++---- tests/Fieldtypes/DateTest.php | 38 ++--------------------------------- 3 files changed, 5 insertions(+), 55 deletions(-) diff --git a/config/system.php b/config/system.php index 1e0485ddad..fc04ee4b02 100644 --- a/config/system.php +++ b/config/system.php @@ -74,21 +74,6 @@ 'date_format' => 'F jS, Y', - /* - |-------------------------------------------------------------------------- - | Display Timezone - |-------------------------------------------------------------------------- - | - | Dates and times are stored in UTC. This setting allows you to determine which - | timezone dates and times are displayed in. For a full list of supported timezones, - | please see the PHP documentation. - | - | https://www.php.net/manual/en/timezones.php - | - */ - - 'display_timezone' => 'UTC', - /* |-------------------------------------------------------------------------- | Default Character Set diff --git a/src/Fieldtypes/Date.php b/src/Fieldtypes/Date.php index 49a5b892db..d9aba7a16c 100644 --- a/src/Fieldtypes/Date.php +++ b/src/Fieldtypes/Date.php @@ -311,7 +311,7 @@ public function augment($value) } if ($value instanceof Carbon) { - return $value->setTimezone(config('statamic.system.display_timezone')); + return $value; } if ($this->config('mode') === 'range') { @@ -321,8 +321,7 @@ public function augment($value) ]; } - $date = $this->parseSaved($value) - ->setTimezone(config('statamic.system.display_timezone')); + $date = $this->parseSaved($value); if (! $this->config('time_enabled')) { $date->startOfDay(); @@ -350,7 +349,7 @@ public function toQueryableValue($value) private function parseSaved($value) { try { - return Carbon::createFromFormat($this->saveFormat(), $value, 'UTC'); + return Carbon::createFromFormat($this->saveFormat(), $value); } catch (InvalidFormatException|InvalidArgumentException $e) { return Carbon::parse($value); } diff --git a/tests/Fieldtypes/DateTest.php b/tests/Fieldtypes/DateTest.php index 703f55ae87..7d6f358bc0 100644 --- a/tests/Fieldtypes/DateTest.php +++ b/tests/Fieldtypes/DateTest.php @@ -26,10 +26,8 @@ public function setUp(): void #[Test] #[DataProvider('augmentProvider')] - public function it_augments(string $displayTimezone, array $config, string $value, string $expected) + public function it_augments($config, $value, $expected) { - config()->set('statamic.system.display_timezone', $displayTimezone); - $augmented = $this->fieldtype($config)->augment($value); $this->assertInstanceOf(Carbon::class, $augmented); @@ -40,52 +38,35 @@ public static function augmentProvider() { return [ 'date' => [ - 'UTC', [], '2012-01-04', '2012 Jan 04 00:00:00', ], 'date with custom format' => [ - 'UTC', ['format' => 'Y--m--d'], '2012--01--04', '2012 Jan 04 00:00:00', ], - 'date with display timezone' => [ - 'America/New_York', - [], - '2012-01-04', - '2012 Jan 04 00:00:00', - ], // The time and seconds configs are important, otherwise - // when parsing dates without times, the time would inherit from "now". + // when when parsing dates without times, the time would inherit from "now". // We need to rely on the configs to know when or when not to reset the time. 'date with time' => [ - 'UTC', ['time_enabled' => true], '2012-01-04 15:32', '2012 Jan 04 15:32:00', ], 'date with time but seconds disabled' => [ - 'UTC', ['time_enabled' => true], '2012-01-04 15:32:54', '2012 Jan 04 15:32:00', ], 'date with time and seconds' => [ - 'UTC', ['time_enabled' => true, 'time_seconds_enabled' => true], '2012-01-04 15:32:54', '2012 Jan 04 15:32:54', ], - 'date with time and display timezone' => [ - 'America/New_York', - ['time_enabled' => true], - '2012-01-04 15:32', - '2012 Jan 04 10:32:00', // -5 hours - ], ]; } @@ -108,21 +89,6 @@ public function it_augments_a_carbon_instance() $this->assertSame($instance, $augmented); } - #[Test] - public function it_augments_a_carbon_instance_using_display_timezone_config() - { - // Could happen if you are using the date fieldtype to augment a manually provided value. - - config()->set('statamic.system.display_timezone', 'America/New_York'); // -5 hours - - $instance = new Carbon('2025-01-01 12:00:00'); - $augmented = $this->fieldtype()->augment($instance); - - $this->assertInstanceOf(Carbon::class, $augmented); - $this->assertEquals('America/New_York', $augmented->getTimezone()->getName()); - $this->assertEquals(7, $instance->hour); // 12pm in UTC is 7am in New York - } - #[Test] public function it_augments_a_range() { From db7cc906f226bd6f344380aef5c98efbac863736 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 11 Feb 2025 17:17:33 +0000 Subject: [PATCH 19/19] UTC forever --- src/Fieldtypes/Date.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Fieldtypes/Date.php b/src/Fieldtypes/Date.php index d9aba7a16c..e0d6679c1a 100644 --- a/src/Fieldtypes/Date.php +++ b/src/Fieldtypes/Date.php @@ -349,7 +349,7 @@ public function toQueryableValue($value) private function parseSaved($value) { try { - return Carbon::createFromFormat($this->saveFormat(), $value); + return Carbon::createFromFormat($this->saveFormat(), $value, 'UTC'); } catch (InvalidFormatException|InvalidArgumentException $e) { return Carbon::parse($value); }