diff --git a/config/system.php b/config/system.php index fc04ee4b024..b73a5db5b85 100644 --- a/config/system.php +++ b/config/system.php @@ -74,6 +74,35 @@ 'date_format' => 'F jS, Y', + /* + |-------------------------------------------------------------------------- + | Timezone + |-------------------------------------------------------------------------- + | + | Statamic will use this timezone when displaying dates on the front-end. + | You can use any timezone supported by PHP. When set to null it will + | fall back to the timezone defined in your `app.php` config file. + | + | https://www.php.net/manual/en/timezones.php + | + */ + + 'display_timezone' => null, + + /* + |-------------------------------------------------------------------------- + | Localize Dates in Modifiers + |-------------------------------------------------------------------------- + | + | When using date-related modifiers, Carbon instances will be in UTC. + | Enabling this setting will ensure that dates get localized into + | the timezone defined in `display_timezone`. Otherwise you'll + | need to manually localize dates in all of your templates. + | + */ + + 'localize_dates_in_modifiers' => false, + /* |-------------------------------------------------------------------------- | Default Character Set diff --git a/package-lock.json b/package-lock.json index fd4a28af785..e5ea4138645 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "statamic", "dependencies": { + "@angelblanco/v-calendar": "^3.1.2", "@floating-ui/dom": "^1.2.5", "@he-tree/vue": "^2.8.2", "@hoppscotch/vue-toasted": "^0.1.0", @@ -76,7 +77,6 @@ "tiny-emitter": "^2.1.0", "uniqid": "^5.2.0", "upload": "^1.3.2", - "v-calendar": "^3.1.2", "validator": "^13.7.0", "vite-svg-loader": "^5.1.0", "vue": "^3.4.27", @@ -117,6 +117,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@angelblanco/v-calendar": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@angelblanco/v-calendar/-/v-calendar-3.1.3.tgz", + "integrity": "sha512-WxIrdPNXNjbkAVzcp2cvngeBQZ3w5pJvwKJMZii26iKtzHFF4E8z6qaDRAgA2UutqTMKNJkoR9JGKHRzUur7Mg==", + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.14.165", + "@types/resize-observer-browser": "^0.1.7", + "date-fns": "^2.16.1", + "date-fns-tz": "^2.0.0", + "lodash": "^4.17.20", + "vue-screen-utils": "^1.0.0-beta.13" + }, + "peerDependencies": { + "@popperjs/core": "^2.0.0", + "vue": "^3.2.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", @@ -6166,23 +6184,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, - "node_modules/v-calendar": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/v-calendar/-/v-calendar-3.1.2.tgz", - "integrity": "sha512-QDWrnp4PWCpzUblctgo4T558PrHgHzDtQnTeUNzKxfNf29FkCeFpwGd9bKjAqktaa2aJLcyRl45T5ln1ku34kg==", - "dependencies": { - "@types/lodash": "^4.14.165", - "@types/resize-observer-browser": "^0.1.7", - "date-fns": "^2.16.1", - "date-fns-tz": "^2.0.0", - "lodash": "^4.17.20", - "vue-screen-utils": "^1.0.0-beta.13" - }, - "peerDependencies": { - "@popperjs/core": "^2.0.0", - "vue": "^3.2.0" - } - }, "node_modules/validator": { "version": "13.12.0", "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", diff --git a/package.json b/package.json index 71fd82672a1..4aad0ce5147 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "knip": "knip" }, "dependencies": { + "@angelblanco/v-calendar": "^3.1.2", "@floating-ui/dom": "^1.2.5", "@he-tree/vue": "^2.8.2", "@hoppscotch/vue-toasted": "^0.1.0", @@ -85,7 +86,6 @@ "tiny-emitter": "^2.1.0", "uniqid": "^5.2.0", "upload": "^1.3.2", - "v-calendar": "^3.1.2", "validator": "^13.7.0", "vite-svg-loader": "^5.1.0", "vue": "^3.4.27", diff --git a/resources/js/bootstrap/fieldtypes.js b/resources/js/bootstrap/fieldtypes.js index d4e853c4d95..3613dc781c2 100644 --- a/resources/js/bootstrap/fieldtypes.js +++ b/resources/js/bootstrap/fieldtypes.js @@ -20,6 +20,7 @@ import Routes from '../components/collections/Routes.vue'; import TitleFormats from '../components/collections/TitleFormats.vue'; import ColorFieldtype from '../components/fieldtypes/ColorFieldtype.vue'; import DateFieldtype from '../components/fieldtypes/DateFieldtype.vue'; +import DateIndexFieldtype from '../components/fieldtypes/DateIndexFieldtype.vue'; import DictionaryFieldtype from '../components/fieldtypes/DictionaryFieldtype.vue'; import DictionaryIndexFieldtype from '../components/fieldtypes/DictionaryIndexFieldtype.vue'; import DictionaryFields from '../components/fieldtypes/DictionaryFields.vue'; @@ -92,6 +93,7 @@ export default function registerFieldtypes(app) { app.component('collection_title_formats-fieldtype', TitleFormats); app.component('color-fieldtype', ColorFieldtype); app.component('date-fieldtype', DateFieldtype); + app.component('date-fieldtype-index', DateIndexFieldtype); app.component('dictionary-fieldtype', DictionaryFieldtype); app.component('dictionary-fieldtype-index', DictionaryIndexFieldtype); app.component('dictionary_fields-fieldtype', DictionaryFields); diff --git a/resources/js/bootstrap/statamic.js b/resources/js/bootstrap/statamic.js index 2a8032442d3..fe1ab842e1b 100644 --- a/resources/js/bootstrap/statamic.js +++ b/resources/js/bootstrap/statamic.js @@ -13,8 +13,8 @@ import useDirtyState from '../composables/dirty-state'; import VueClickAway from 'vue3-click-away'; import FloatingVue from 'floating-vue'; import 'floating-vue/dist/style.css'; -import VCalendar from 'v-calendar'; -import 'v-calendar/style.css'; +import VCalendar from '@angelblanco/v-calendar'; +import '@angelblanco/v-calendar/style.css'; import Toasts from '../components/Toasts'; import PortalVue from 'portal-vue'; import Keys from '../components/keys/Keys'; diff --git a/resources/js/components/fieldtypes/DateFieldtype.vue b/resources/js/components/fieldtypes/DateFieldtype.vue index d79b70f6c7f..adc9e4019e8 100644 --- a/resources/js/components/fieldtypes/DateFieldtype.vue +++ b/resources/js/components/fieldtypes/DateFieldtype.vue @@ -15,7 +15,7 @@ @@ -25,12 +25,12 @@ v-if="hasTime" ref="time" handle="" - :value="value.time" + :value="localValue.time" :required="config.time_enabled" :show-seconds="config.time_seconds_enabled" :read-only="isReadOnly" :config="{}" - @input="setTime" + @update:value="setLocalTime" /> @@ -44,6 +44,7 @@ import SingleInline from './date/SingleInline.vue'; import RangePopover from './date/RangePopover.vue'; import RangeInline from './date/RangeInline.vue'; import { useScreens } from 'vue-screen-utils'; +import { toRaw } from 'vue'; export default { components: { @@ -55,7 +56,7 @@ export default { mixins: [Fieldtype], - inject: ['storeName'], + inject: ['store'], setup() { const { mapCurrent } = useScreens({ @@ -72,6 +73,7 @@ export default { return { containerWidth: null, focusedField: null, + localValue: null, }; }, @@ -85,6 +87,10 @@ export default { }, hasDate() { + if (this.isRange) { + return this.config.required || this.value?.start || this.value?.end; + } + return this.config.required || this.value.date; }, @@ -93,7 +99,7 @@ export default { }, hasSeconds() { - return this.config.time_has_seconds; + return this.config.time_seconds_enabled; }, isSingle() { @@ -120,14 +126,19 @@ export default { }, datePickerValue() { - if (this.isRange) return this.value.date; + if (this.isRange) { + return { + start: this.localValue?.start?.date, + end: this.localValue?.end?.date, + }; + } // The calendar component will do `new Date(datePickerValue)` under the hood. // If you pass a date without a time, it will treat it as UTC. By adding a time, // it will behave as local time. The date that comes from the server will be what // we expect. The time is handled separately by the nested time fieldtype. // https://github.com/statamic/cms/pull/6688 - return this.value.date + 'T00:00:00'; + return this.localValue.date + 'T00:00:00'; }, commonDatePickerBindings() { @@ -167,20 +178,23 @@ export default { replicatorPreview() { if (!this.showFieldPreviews || !this.config.replicator_preview) return; - if (!this.value.date) return; if (this.isRange) { + if (!this.localValue?.start) return; + return ( - this.$moment(this.value.date.start).format(this.displayFormat) + + this.$moment(this.localValue.start.date).format(this.displayFormat) + ' – ' + - this.$moment(this.value.date.end).format(this.displayFormat) + this.$moment(this.localValue.end.date).format(this.displayFormat) ); } - let preview = this.$moment(this.value.date).format(this.displayFormat); + if (!this.localValue?.date) return; - if (this.hasTime && this.value.time) { - preview += ` ${this.value.time}`; + let preview = this.$moment(this.localValue.date).format(this.displayFormat); + + if (this.hasTime && this.localValue.time) { + preview += ` ${this.localValue.time}`; } return preview; @@ -188,43 +202,170 @@ export default { }, created() { - if (this.value.time === 'now') { - // Probably shouldn't be modifying a prop, but luckily it all works nicely, without - // needing to create an "update value without triggering dirty state" flow yet. - this.value.time = this.$moment().format(this.hasSeconds ? 'HH:mm:ss' : 'HH:mm'); - } - this.$events.$on(`container.${this.storeName}.saving`, this.triggerChangeOnFocusedField); }, + mounted() { + if (this.isRange && this.config.required && !this.value) { + this.addDate(); + } + }, + unmounted() { this.$events.$off(`container.${this.storeName}.saving`, this.triggerChangeOnFocusedField); }, + watch: { + value: { + immediate: true, + handler(value, oldValue) { + if (this.isRange) { + if (!value || !value.start) { + this.localValue = { start: { date: null, time: null }, end: { date: null, time: null } }; + return; + } + + let localValue = { + start: this.createLocalFromUtc(value.start), + end: this.createLocalFromUtc(value.end), + }; + + if (JSON.stringify(toRaw(this.localValue)) === JSON.stringify(localValue)) { + return; + } + + this.localValue = localValue; + + return; + } + + if (!value || !value.date) { + this.localValue = { date: null, time: null }; + return; + } + + let localValue = this.createLocalFromUtc(value); + + if (JSON.stringify(toRaw(this.localValue)) === JSON.stringify(localValue)) { + return; + } + + this.localValue = localValue; + }, + }, + + localValue(value) { + if (this.isRange) { + this.update({ + start: this.createUtcFromLocal(value.start), + end: this.createUtcFromLocal(value.end), + }); + + return; + } + + this.update(this.createUtcFromLocal(value)); + }, + }, + methods: { + createLocalFromUtc(utcValue) { + const dateTime = new Date(utcValue.date + 'T' + (utcValue.time || '00:00:00') + 'Z'); + + let date = + dateTime.getFullYear() + + '-' + + (dateTime.getMonth() + 1).toString().padStart(2, '0') + + '-' + + dateTime.getDate().toString().padStart(2, '0'); + let time = + dateTime.getHours().toString().padStart(2, '0') + + ':' + + dateTime.getMinutes().toString().padStart(2, '0'); + + if (this.hasSeconds) { + time += ':' + dateTime.getSeconds().toString().padStart(2, '0'); + } + + return { date, time }; + }, + + createUtcFromLocal(localValue) { + const dateTime = new Date(localValue.date + 'T' + (localValue.time || '00:00:00')); + + let date = + dateTime.getUTCFullYear() + + '-' + + (dateTime.getUTCMonth() + 1).toString().padStart(2, '0') + + '-' + + dateTime.getUTCDate().toString().padStart(2, '0'); + let time = + dateTime.getUTCHours().toString().padStart(2, '0') + + ':' + + dateTime.getUTCMinutes().toString().padStart(2, '0'); + + if (this.hasSeconds) { + time += ':' + dateTime.getUTCSeconds().toString().padStart(2, '0'); + } + + return { date, time }; + }, + triggerChangeOnFocusedField() { if (!this.focusedField) return; this.focusedField.dispatchEvent(new Event('change')); }, - setDate(date) { + setLocalDate(date) { + if (this.isRange) { + this.localValue = { + start: { date: date.start, time: '00:00' }, + end: { date: date.end, time: '23:59' }, + }; + + return; + } + if (!date) { - this.update({ date: null, time: null }); + this.localValue = { date: null, time: null }; return; } - this.update({ ...this.value, date }); + this.localValue = { + date, + time: this.config.time_enabled ? this.localValue.time : '00:00', + }; }, - setTime(time) { - this.update({ ...this.value, time }); + setLocalTime(time) { + this.localValue = { ...this.localValue, time }; }, addDate() { - const now = this.$moment().format(this.format); - const date = this.isRange ? { start: now, end: now } : now; - this.update({ date, time: null }); + let now = new Date(); + + if (!this.config.time_enabled) { + now.setHours(0, 0, 0, 0); + } + + let date = + now.getFullYear() + + '-' + + String(now.getMonth() + 1).padStart(2, '0') + + '-' + + String(now.getDate()).padStart(2, '0'); + + let time = now.toLocaleTimeString(undefined, { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: this.hasSeconds ? '2-digit' : undefined, + }); + + this.localValue = this.isRange + ? { start: { date, time: '00:00' }, end: { date, time: '23:59' } } + : { date, time }; }, }, }; diff --git a/resources/js/components/fieldtypes/DateIndexFieldtype.vue b/resources/js/components/fieldtypes/DateIndexFieldtype.vue new file mode 100644 index 00000000000..40e30126929 --- /dev/null +++ b/resources/js/components/fieldtypes/DateIndexFieldtype.vue @@ -0,0 +1,35 @@ + + + diff --git a/resources/js/tests/components/fieldtypes/DateFieldtype.test.js b/resources/js/tests/components/fieldtypes/DateFieldtype.test.js new file mode 100644 index 00000000000..a9a764ee46c --- /dev/null +++ b/resources/js/tests/components/fieldtypes/DateFieldtype.test.js @@ -0,0 +1,218 @@ +import { mount } from '@vue/test-utils'; +import { test, expect } from 'vitest'; +import DateFieldtype from '@/components/fieldtypes/DateFieldtype.vue'; +import TimeFieldtype from '@/components/fieldtypes/TimeFieldtype.vue'; +import SvgIcon from '@/components/SvgIcon.vue'; +import { createPinia } from 'pinia'; + +window.__ = (key) => key; + +window.matchMedia = () => ({ + addEventListener: () => {}, +}); + +process.env.TZ = 'America/New_York'; + +const makeDateField = (props = {}) => { + return mount(DateFieldtype, { + props: { + handle: 'date', + config: { + earliest_date: { date: null, time: null }, + latest_date: { date: null, time: null }, + }, + ...props, + }, + components: { + SvgIcon, + TimeFieldtype, + }, + plugins: [createPinia()], + global: { + provide: { + store: '', + }, + mocks: { + $config: { + get: (key) => { + if (key === 'locale') { + return 'en'; + } + }, + }, + $events: { + $on: () => {}, + }, + }, + }, + }); +}; + +test('date and time is localized to the users timezone', async () => { + const dateField = makeDateField({ + value: { date: '2025-01-01', time: '15:00' }, + }); + + expect(dateField.vm.localValue).toMatchObject({ + date: '2025-01-01', + time: '10:00', + }); +}); + +test('date can be updated', async () => { + const dateField = makeDateField({ + value: { date: '2025-01-01', time: '10:00' }, + }); + + await dateField.vm.setLocalDate('2024-12-10'); + + expect(dateField.emitted('update:value')[0]).toEqual([ + { + date: '2024-12-10', + time: '05:00', + }, + ]); + + expect(dateField.vm.localValue).toMatchObject({ + date: '2024-12-10', + time: '00:00', + }); +}); + +test('date can be updated without resetting the time when time_enabled is true', async () => { + const dateField = makeDateField({ + config: { + earliest_date: { date: null, time: null }, + latest_date: { date: null, time: null }, + time_enabled: true, + }, + value: { date: '2025-01-01', time: '10:00' }, + }); + + await dateField.vm.setLocalDate('2024-12-10'); + + expect(dateField.emitted('update:value')[0]).toEqual([ + { + date: '2024-12-10', + time: '10:00', + }, + ]); + + expect(dateField.vm.localValue).toMatchObject({ + date: '2024-12-10', + time: '05:00', + }); +}); + +test('time can be updated', async () => { + const dateField = makeDateField({ + config: { + earliest_date: { date: null, time: null }, + latest_date: { date: null, time: null }, + time_enabled: true, + }, + value: { date: '2025-01-01', time: '15:00' }, + }); + + await dateField.vm.setLocalTime('23:11'); + + expect(dateField.emitted('update:value')[0]).toEqual([ + { + date: '2025-01-02', + time: '04:11', + }, + ]); + + expect(dateField.vm.localValue).toMatchObject({ + date: '2025-01-01', + time: '23:11', + }); +}); + +test('time with seconds can be updated', async () => { + const dateField = makeDateField({ + config: { + earliest_date: { date: null, time: null }, + latest_date: { date: null, time: null }, + time_seconds_enabled: true, + }, + value: { date: '2025-01-01', time: '15:00:00' }, + }); + + await dateField.vm.setLocalTime('23:11:11'); + + expect(dateField.emitted('update:value')[0]).toEqual([ + { + date: '2025-01-02', + time: '04:11:11', + }, + ]); + + expect(dateField.vm.localValue).toMatchObject({ + date: '2025-01-01', + time: '23:11:11', + }); +}); + +test('date range can be updated', async () => { + const dateField = makeDateField({ + config: { + earliest_date: { date: null, time: null }, + latest_date: { date: null, time: null }, + mode: 'range', + }, + value: { + start: { date: '2025-01-01', time: '05:00' }, + end: { date: '2025-01-08', time: '04:59' }, + }, + }); + + await dateField.vm.setLocalDate({ + start: '2025-01-01', + end: '2025-01-30', + }); + + expect(dateField.emitted('update:value')[0]).toEqual([ + { + start: { date: '2025-01-01', time: '05:00' }, + end: { date: '2025-01-31', time: '04:59' }, + }, + ]); + + expect(dateField.vm.localValue).toMatchObject({ + start: { date: '2025-01-01', time: '00:00' }, + end: { date: '2025-01-30', time: '23:59' }, + }); +}); + +test('required date range field with null value is automatically populated', async () => { + const dateField = makeDateField({ + config: { + earliest_date: { date: null, time: null }, + latest_date: { date: null, time: null }, + mode: 'range', + required: true, + }, + value: null, + }); + + const today = new Date().toISOString().split('T')[0]; + + expect(dateField.vm.localValue).toMatchObject({ + start: { date: today, time: '00:00' }, + end: { date: today, time: '23:59' }, + }); +}); + +test('local time is updated when value prop is updated', async () => { + const dateField = makeDateField({ + value: { date: '2025-01-01', time: '15:00' }, + }); + + await dateField.setProps({ value: { date: '2025-01-01', time: '10:00' } }); + + expect(dateField.vm.localValue).toMatchObject({ + date: '2025-01-01', + time: '05:00', + }); +}); diff --git a/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js b/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js new file mode 100644 index 00000000000..ce64153548c --- /dev/null +++ b/resources/js/tests/components/fieldtypes/DateIndexFieldtype.test.js @@ -0,0 +1,73 @@ +import { mount } from '@vue/test-utils'; +import { test, expect } from 'vitest'; +import DateIndexFieldtype from '@/components/fieldtypes/DateIndexFieldtype.vue'; +import Moment from 'moment'; + +window.__ = (key) => key; + +window.matchMedia = () => ({ + addEventListener: () => {}, +}); + +process.env.TZ = 'America/New_York'; + +const makeDateIndexField = (value = {}) => { + return mount(DateIndexFieldtype, { + props: { + handle: 'date', + value, + values: {}, + }, + global: { + mocks: { + $moment: (date) => { + return Moment(date); + }, + }, + }, + }); +}; + +test('date is localized to the users timezone', async () => { + const dateIndexField = makeDateIndexField({ + date: '2025-01-01', + time: '05:00', + mode: 'single', + display_format: 'YYYY-MM-DD', + }); + + expect(dateIndexField.vm.formatted).toBe('2025-01-01'); +}); + +test('date and time is localized to the users timezone', async () => { + const dateIndexField = makeDateIndexField({ + date: '2025-01-01', + time: '15:00', + mode: 'single', + display_format: 'YYYY-MM-DD HH:mm', + }); + + expect(dateIndexField.vm.formatted).toBe('2025-01-01 10:00'); +}); + +test('date range is localized to the users timezone', async () => { + const dateIndexField = makeDateIndexField({ + start: { date: '2025-01-01', time: '05:00' }, + end: { date: '2025-01-11', time: '04:59' }, + mode: 'range', + display_format: 'YYYY-MM-DD', + }); + + expect(dateIndexField.vm.formatted).toBe('2025-01-01 – 2025-01-10'); +}); + +test('configured display format is respected', async () => { + const dateIndexField = makeDateIndexField({ + date: '2025-01-01', + time: '15:00:15', + mode: 'single', + display_format: 'DD/MM/YYYY HH:mm:ss', + }); + + expect(dateIndexField.vm.formatted).toBe('01/01/2025 10:00:15'); +}); diff --git a/src/Console/Commands/MigrateDatesToUtc.php b/src/Console/Commands/MigrateDatesToUtc.php new file mode 100644 index 00000000000..808d0f16c51 --- /dev/null +++ b/src/Console/Commands/MigrateDatesToUtc.php @@ -0,0 +1,269 @@ +currentTimezone = $this->argument('timezone'); + + $this->components->warn('This command makes changes to content. Please make a backup before running.'); + $this->components->info('This operation converts content dates to UTC for storage purposes only. System functionality is identical whether you proceed or not – do so only if specifically desired.'); + + if (! confirm('Do you want to continue', default: false)) { + return; + } + + $items = $this->getItemsContainingData(); + + if ($items->isEmpty()) { + $this->components->warn('No content found. Exiting.'); + + return; + } + + $progress = progress( + label: 'Converting dates to UTC', + steps: $items->count(), + hint: 'This may take a while depending on the amount of content you have.' + ); + + $progress->start(); + + $items->each(function ($item) use ($progress) { + $progress->advance(); + + /** @var Fields $fields */ + $fields = $item->blueprint()->fields(); + + $this->recursivelyUpdateFields($item, $fields); + + if (method_exists($item, 'isDirty')) { + if ($item->isDirty()) { + $item->saveQuietly(); + } + } else { + $item->saveQuietly(); + } + + $progress->advance(); + }); + + $progress->finish(); + + $this->components->info("Migrated dates from [{$this->currentTimezone}] to [UTC]."); + + $this->components->bulletList([ + 'You may now safely change your application\'s timezone to UTC', + "If you're storing content in a database, or outside of version control, you will need to run this command after deploying", + ]); + } + + 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' + ) { + // When entries are constructed, the datestamp from the filename would be provided but treated as UTC. + // We need them to be adjusted back to the existing timezone. + $item->date(Carbon::createFromFormat( + $format = 'Y-m-d H:i:s', + $item->date()->format($format), + $this->currentTimezone + )); + + return; + } + + $data = $item->data()->all(); + + $dottedKey = $dottedPrefix.$field->handle(); + + if (! Arr::has($data, $dottedKey)) { + return; + } + + $value = Arr::get($data, $dottedKey); + + $value = $field->get('mode') === 'range' + ? $this->processRange($value, $field) + : $this->processSingle($value, $field); + + Arr::set($data, $dottedKey, $value); + + $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); + } + }); + }); + + 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()}"; + + $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); + } + }); + }); + + 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(string|array $value, Field $field): array + { + if (! is_array($value)) { + $value = ['start' => $value, 'end' => $value]; + } + + 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, $this->currentTimezone) + ->utc() + ->format($field->get('format', $this->defaultFormat($field))); + + if (is_numeric($value)) { + $value = (int) $value; + } + + return $value; + } + + private function defaultFormat(Field $field): string + { + return $field->get('time_seconds_enabled') + ? DateFieldtype::DEFAULT_DATETIME_WITH_SECONDS_FORMAT + : DateFieldtype::DEFAULT_DATETIME_FORMAT; + } +} diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index decfbd60945..a2a60e27c35 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -480,11 +480,13 @@ public function buildPath() if ($this->hasDate() && $this->date) { $format = 'Y-m-d'; + if ($this->hasTime()) { $format .= '-Hi'; - if ($this->hasSeconds()) { - $format .= 's'; - } + } + + if ($this->hasSeconds()) { + $format .= 's'; } $prefix = $this->date->format($format).'.'; @@ -561,17 +563,7 @@ public function date($date = null) return null; } - $date = $date ?? optional($this->origin())->date() ?? $this->lastModified(); - - if (! $this->hasTime()) { - $date->startOfDay(); - } - - if (! $this->hasSeconds()) { - $date->startOfMinute(); - } - - return $date; + return $date ?? optional($this->origin())->date() ?? $this->adjustDateTimeBasedOnSettings($this->lastModified()); }) ->setter(function ($date) { if (! $this->collection()?->dated()) { @@ -583,22 +575,42 @@ public function date($date = null) } if ($date instanceof \Carbon\CarbonInterface) { - return $date; - } - - if (strlen($date) === 10) { - return Carbon::createFromFormat('Y-m-d', $date)->startOfDay(); + return $date->utc(); } - if (strlen($date) === 15) { - return Carbon::createFromFormat('Y-m-d-Hi', $date)->startOfMinute(); - } + $date = $this->parseDateFromString($date); - return Carbon::createFromFormat('Y-m-d-His', $date); + return $this->adjustDateTimeBasedOnSettings($date); }) ->args(func_get_args()); } + private function parseDateFromString($date) + { + if (strlen($date) === 10) { + return Carbon::createFromFormat('Y-m-d', $date)->startOfDay(); + } + + if (strlen($date) === 15) { + return Carbon::createFromFormat('Y-m-d-Hi', $date)->startOfMinute(); + } + + return Carbon::createFromFormat('Y-m-d-His', $date); + } + + private function adjustDateTimeBasedOnSettings($date) + { + if (! $this->hasTime()) { + $date->startOfDay(); + } + + if (! $this->hasSeconds()) { + $date->startOfMinute(); + } + + return $date; + } + public function hasDate() { return $this->collection()->dated(); @@ -610,7 +622,11 @@ public function hasTime() return false; } - return $this->blueprint()->field('date')->fieldtype()->timeEnabled(); + if ($this->blueprint()->field('date')->fieldtype()->timeEnabled()) { + return true; + } + + return $this->date && ! $this->date->isStartOfDay(); } public function hasSeconds() @@ -897,11 +913,13 @@ public function routeData() ]); if ($this->hasDate()) { + $date = $this->date()->setTimezone(Statamic::displayTimezone()); + $data = $data->merge([ - 'date' => $this->date(), - 'year' => $this->date()->format('Y'), - 'month' => $this->date()->format('m'), - 'day' => $this->date()->format('d'), + 'date' => $date, + 'year' => $date->format('Y'), + 'month' => $date->format('m'), + 'day' => $date->format('d'), ]); } diff --git a/src/Fieldtypes/Date.php b/src/Fieldtypes/Date.php index d9aba7a16c1..adc562326cc 100644 --- a/src/Fieldtypes/Date.php +++ b/src/Fieldtypes/Date.php @@ -133,10 +133,8 @@ private function preProcessSingle($value) if ($value === 'now') { return [ - // We want the current date and time to be rendered, but since we don't - // know the users timezone, we'll let the front-end handle it. - 'date' => now()->startOfDay()->format(self::DEFAULT_DATE_FORMAT), - 'time' => $this->config('time_enabled') ? 'now' : null, // This will get replaced with the current time in Vue component. + 'date' => now(tz: 'UTC')->format(self::DEFAULT_DATE_FORMAT), + 'time' => now(tz: 'UTC')->format($this->config('time_seconds_enabled') ? 'H:i:s' : 'H:i'), ]; } @@ -152,42 +150,36 @@ private function preProcessSingle($value) private function preProcessRange($value) { - $vueFormat = $this->defaultFormat(); - + // If there's no value, return null, so we can handle the empty state on the Vue side. if (! $value) { - return $this->splitDateTimeForPreProcessRange($this->isRequired() ? [ - 'start' => Carbon::now()->format($vueFormat), - 'end' => Carbon::now()->format($vueFormat), - ] : null); + return null; } - // If the value is a string, this field probably used to be a single date. + // If the value isn't an array, this field probably used to be a single date. // In this case, we'll use the date for both the start and end of the range. - if (is_string($value)) { - $value = ['start' => $value, 'end' => $value]; + if (! is_array($value)) { + $carbon = $this->parseSavedToCarbon($value); + + return [ + 'start' => $this->splitDateTimeForPreProcessSingle($carbon->copy()->startOfDay()->utc()), + 'end' => $this->splitDateTimeForPreProcessSingle($carbon->copy()->endOfDay()->utc()), + ]; } - return $this->splitDateTimeForPreProcessRange([ - 'start' => $this->parseSaved($value['start'])->format($vueFormat), - 'end' => $this->parseSaved($value['end'])->format($vueFormat), - ]); + return [ + 'start' => $this->preProcessSingle($value['start']), + 'end' => $this->preProcessSingle($value['end']), + ]; } private function splitDateTimeForPreProcessSingle(Carbon $carbon) { return [ 'date' => $carbon->format(self::DEFAULT_DATE_FORMAT), - 'time' => $this->config('time_enabled') - ? $carbon->format($this->config('time_seconds_enabled') ? 'H:i:s' : 'H:i') - : null, + 'time' => $carbon->format($this->config('time_seconds_enabled') ? 'H:i:s' : 'H:i'), ]; } - private function splitDateTimeForPreProcessRange(?array $range = null) - { - return ['date' => $range, 'time' => null]; - } - public function isRequired() { return in_array('required', $this->field->rules()[$this->field->handle()]); @@ -195,7 +187,7 @@ public function isRequired() public function process($data) { - if (is_null($data) || is_null($data['date'])) { + if (is_null($data)) { return null; } @@ -204,29 +196,28 @@ public function process($data) private function processSingle($data) { + if (is_null($data['date'])) { + return null; + } + return $this->processDateTime($data['date'].' '.($data['time'] ?? '00:00')); } private function processRange($data) { - $date = $data['date']; + if (is_null($data['start'])) { + return null; + } return [ - 'start' => $this->processDateTime($date['start']), - 'end' => $this->processDateTimeEndOfDay($date['end']), + 'start' => $this->processDateTime($data['start']['date'].' '.($data['start']['time'] ?? '00:00')), + 'end' => $this->processDateTime($data['end']['date'].' '.($data['end']['time'] ?? '23:59')), ]; } private function processDateTime($value) { - $date = Carbon::parse($value); - - return $this->formatAndCast($date, $this->saveFormat()); - } - - private function processDateTimeEndOfDay($value) - { - $date = Carbon::parse($value)->endOfDay(); + $date = Carbon::parse($value, 'UTC'); return $this->formatAndCast($date, $this->saveFormat()); } @@ -244,10 +235,15 @@ public function preProcessIndex($value) $value = ['start' => $value, 'end' => $value]; } - $start = $this->parseSaved($value['start'])->format($this->indexDisplayFormat()); - $end = $this->parseSaved($value['end'])->format($this->indexDisplayFormat()); + $start = $this->parseSaved($value['start']); + $end = $this->parseSaved($value['end']); - return $start.' - '.$end; + return [ + 'start' => $this->splitDateTimeForPreProcessSingle($start), + 'end' => $this->splitDateTimeForPreProcessSingle($end), + 'mode' => $this->config('mode', 'single'), + 'display_format' => DateFormat::toIso($this->indexDisplayFormat()), + ]; } // If the value is an array, this field probably used to be a range. In this case, we'll use the start date. @@ -255,7 +251,13 @@ public function preProcessIndex($value) $value = $value['start']; } - return $this->parseSaved($value)->format($this->indexDisplayFormat()); + $date = $this->parseSaved($value); + + return [ + ...$this->splitDateTimeForPreProcessSingle($date), + 'mode' => $this->config('mode', 'single'), + 'display_format' => DateFormat::toIso($this->indexDisplayFormat()), + ]; } private function saveFormat() @@ -277,18 +279,18 @@ public function fieldDisplayFormat() private function defaultFormat() { - if ($this->config('time_enabled') && $this->config('mode', 'single') === 'single') { - return $this->config('time_seconds_enabled') - ? self::DEFAULT_DATETIME_WITH_SECONDS_FORMAT - : self::DEFAULT_DATETIME_FORMAT; + if ($this->config('mode', 'single') === 'range') { + return self::DEFAULT_DATETIME_FORMAT; } - return self::DEFAULT_DATE_FORMAT; + return $this->config('time_seconds_enabled') + ? self::DEFAULT_DATETIME_WITH_SECONDS_FORMAT + : self::DEFAULT_DATETIME_FORMAT; } private function formatAndCast(Carbon $date, $format) { - $formatted = $date->format($format); + $formatted = $date->setTimezone(config('app.timezone'))->format($format); if (is_numeric($formatted)) { $formatted = (int) $formatted; @@ -323,9 +325,7 @@ public function augment($value) $date = $this->parseSaved($value); - if (! $this->config('time_enabled')) { - $date->startOfDay(); - } elseif (! $this->config('time_seconds_enabled')) { + if (! $this->config('time_seconds_enabled')) { $date->startOfMinute(); } @@ -348,10 +348,33 @@ public function toQueryableValue($value) private function parseSaved($value) { + $hasTime = false; + + if (is_int($value)) { + $hasTime = true; + } elseif (str_contains($this->saveFormat(), 'H')) { + $hasTime = true; + } + + $carbon = $this->parseSavedToCarbon($value); + + if (! $hasTime) { + $carbon = $carbon->startOfDay(); + } + + return $carbon->utc(); + } + + private function parseSavedToCarbon($value): Carbon + { + if ($value instanceof Carbon) { + return $value; + } + try { - return Carbon::createFromFormat($this->saveFormat(), $value); + return Carbon::createFromFormat($this->saveFormat(), $value, config('app.timezone')); } catch (InvalidFormatException|InvalidArgumentException $e) { - return Carbon::parse($value); + return Carbon::parse($value, config('app.timezone')); } } diff --git a/src/Http/Controllers/CP/Collections/ExtractsFromEntryFields.php b/src/Http/Controllers/CP/Collections/ExtractsFromEntryFields.php index 3c169b63e36..7223ce051f1 100644 --- a/src/Http/Controllers/CP/Collections/ExtractsFromEntryFields.php +++ b/src/Http/Controllers/CP/Collections/ExtractsFromEntryFields.php @@ -25,8 +25,7 @@ protected function extractFromFields($entry, $blueprint) } if ($entry->collection()->dated()) { - $datetime = substr($entry->date()->toDateTimeString(), 0, 19); - $datetime = ($entry->hasTime()) ? $datetime : substr($datetime, 0, 10); + $datetime = substr($entry->date()->setTimezone(config('app.timezone'))->toDateTimeString(), 0, 19); $values['date'] = $datetime; } diff --git a/src/Http/Middleware/Localize.php b/src/Http/Middleware/Localize.php index dbb32631553..2df00b3644d 100644 --- a/src/Http/Middleware/Localize.php +++ b/src/Http/Middleware/Localize.php @@ -32,7 +32,9 @@ public function handle($request, Closure $next) // Get original Carbon format so it can be restored later. $originalToStringFormat = $this->getToStringFormat(); - Date::setToStringFormat(Statamic::dateFormat()); + Date::setToStringFormat(function (Carbon $date) { + return $date->setTimezone(Statamic::displayTimezone())->format(Statamic::dateFormat()); + }); $response = $next($request); @@ -51,7 +53,7 @@ public function handle($request, Closure $next) * * @throws \ReflectionException */ - private function getToStringFormat(): ?string + private function getToStringFormat(): string|\Closure|null { $reflection = new ReflectionClass($date = Date::now()); diff --git a/src/Modifiers/CoreModifiers.php b/src/Modifiers/CoreModifiers.php index e3fc29558bf..1d8ababb754 100644 --- a/src/Modifiers/CoreModifiers.php +++ b/src/Modifiers/CoreModifiers.php @@ -27,6 +27,7 @@ use Statamic\Fieldtypes\Bard; use Statamic\Fieldtypes\Bard\Augmentor; use Statamic\Fieldtypes\Link\ArrayableLink; +use Statamic\Statamic; use Statamic\Support\Arr; use Statamic\Support\Dumper; use Statamic\Support\Html; @@ -2791,7 +2792,7 @@ public function timestamp($value) */ public function timezone($value, $params) { - $timezone = Arr::get($params, 0, Config::get('app.timezone')); + $timezone = Arr::get($params, 0, Statamic::displayTimezone()); return $this->carbon($value)->tz($timezone); } @@ -3213,6 +3214,10 @@ private function carbon($value) $value = (is_numeric($value)) ? Date::createFromTimestamp($value, config('app.timezone')) : Date::parse($value); } + if (config('statamic.system.localize_dates_in_modifiers')) { + $value->setTimezone(Statamic::displayTimezone()); + } + return $value; } diff --git a/src/Providers/ConsoleServiceProvider.php b/src/Providers/ConsoleServiceProvider.php index 45863744802..e57ef3aee7a 100644 --- a/src/Providers/ConsoleServiceProvider.php +++ b/src/Providers/ConsoleServiceProvider.php @@ -29,6 +29,7 @@ class ConsoleServiceProvider extends ServiceProvider Commands\MakeFilter::class, Commands\MakeTag::class, Commands\MakeWidget::class, + Commands\MigrateDatesToUtc::class, Commands\MakeUser::class, Commands\Rtfm::class, Commands\StacheClear::class, diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index 6883830cd2a..c3afa316c70 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\AddTimezoneConfigOptions::class, Updates\RemoveParentField::class, ]; diff --git a/src/Stache/Stores/CollectionEntriesStore.php b/src/Stache/Stores/CollectionEntriesStore.php index 5c6450e575e..2c808ded22c 100644 --- a/src/Stache/Stores/CollectionEntriesStore.php +++ b/src/Stache/Stores/CollectionEntriesStore.php @@ -2,6 +2,7 @@ namespace Statamic\Stache\Stores; +use Carbon\Carbon; use Statamic\Entries\GetDateFromPath; use Statamic\Entries\GetSlugFromPath; use Statamic\Entries\GetSuffixFromPath; @@ -96,7 +97,7 @@ public function makeItemFromFile($path, $contents) // } if ($collection->dated()) { - $entry->date((new GetDateFromPath)($path)); + $entry->date($this->getDateFromPath($path)); } // Blink the entry so that it can be used when building the URI. If it's not @@ -290,4 +291,25 @@ private function updateEntriesWithinStore($ids) $entries->each(fn ($entry) => $this->cacheItem($entry)); } + + private function getDateFromPath($path) + { + if (! $date = (new GetDateFromPath)($path)) { + return null; + } + + $format = match (strlen($date)) { + 10 => 'Y-m-d', + 15 => 'Y-m-d-Hi', + 17 => 'Y-m-d-His', + }; + + $carbon = Carbon::createFromFormat($format, $date, config('app.timezone')); + + if (strlen($date) === 10) { + $carbon->startOfDay(); + } + + return $carbon->utc(); + } } diff --git a/src/Statamic.php b/src/Statamic.php index 9818e4a437c..065c44c3a03 100644 --- a/src/Statamic.php +++ b/src/Statamic.php @@ -322,6 +322,11 @@ public static function dateTimeFormat() return DateFormat::containsTime($format) ? $format : $format.' H:i'; } + public static function displayTimezone(): string + { + return config('statamic.system.display_timezone') ?? config('app.timezone'); + } + public static function flash() { if ($success = session('success')) { diff --git a/src/UpdateScripts/AddTimezoneConfigOptions.php b/src/UpdateScripts/AddTimezoneConfigOptions.php new file mode 100644 index 00000000000..5238fb4e7cd --- /dev/null +++ b/src/UpdateScripts/AddTimezoneConfigOptions.php @@ -0,0 +1,42 @@ +isUpdatingTo('6.0.0'); + } + + public function update(): void + { + if (! File::exists($path = app()->configPath('statamic/system.php'))) { + return; + } + + $systemConfig = File::get($path); + + if (Str::contains($systemConfig, 'display_timezone')) { + return; + } + + $lineNumberOfDateFormatOption = collect(explode("\n", $systemConfig)) + ->filter(fn ($line) => Str::contains($line, 'date_format')) + ->keys() + ->first(); + + $systemConfig = Str::of($systemConfig) + ->explode("\n") + ->put($lineNumberOfDateFormatOption + 1, File::get(__DIR__.'/stubs/system_timezone_config.php.stub')) + ->implode("\n"); + + File::put(app()->configPath('statamic/system.php'), $systemConfig); + } +} diff --git a/src/UpdateScripts/stubs/system_timezone_config.php.stub b/src/UpdateScripts/stubs/system_timezone_config.php.stub new file mode 100644 index 00000000000..4eaf0a60345 --- /dev/null +++ b/src/UpdateScripts/stubs/system_timezone_config.php.stub @@ -0,0 +1,29 @@ + + /* + |-------------------------------------------------------------------------- + | Timezone + |-------------------------------------------------------------------------- + | + | Statamic will use this timezone when displaying dates on the front-end. + | You can use any timezone supported by PHP. When set to null it will + | fall back to the timezone defined in your `app.php` config file. + | + | https://www.php.net/manual/en/timezones.php + | + */ + + 'display_timezone' => null, + + /* + |-------------------------------------------------------------------------- + | Localize Dates in Modifiers + |-------------------------------------------------------------------------- + | + | When using date-related modifiers, Carbon instances will be in UTC. + | Enabling this setting will ensure that dates get localized into + | the timezone defined in `display_timezone`. Otherwise you'll + | need to manually localize dates in all of your templates. + | + */ + + 'localize_dates_in_modifiers' => true, diff --git a/tests/Console/Commands/MigrateDatesToUtcTest.php b/tests/Console/Commands/MigrateDatesToUtcTest.php new file mode 100644 index 00000000000..e275c3279b3 --- /dev/null +++ b/tests/Console/Commands/MigrateDatesToUtcTest.php @@ -0,0 +1,271 @@ +setContents(['fields' => [ + ['handle' => 'fieldset_date', 'field' => ['type' => 'date', 'time_enabled' => true]], + ]])->save(); + } + + public function tearDown(): void + { + File::delete(base_path('resources/blueprints')); + File::delete(base_path('resources/fieldsets')); + + parent::tearDown(); + } + + protected function defineEnvironment($app) + { + tap($app['config'], function ($config) { + // Set the timezone to something different from UTC and what the commands would be run with. + $config->set('app.timezone', 'America/New_York'); + }); + } + + #[Test] + #[DataProvider('dateFieldsProvider')] + public function it_converts_date_fields_in_entries(string $fieldHandle, array $field, $original, $expected) + { + $collection = tap(Collection::make('articles'))->save(); + $collection->entryBlueprint()->setContents(['fields' => [$field]])->save(); + + $entry = Entry::make()->collection('articles')->data([$fieldHandle => $original]); + $entry->save(); + + $this->migrateDatesToUtc(); + + $this->assertEquals($expected, $entry->fresh()->get($fieldHandle)); + } + + #[Test] + public function it_converts_entry_date_field_in_entries() + { + $collection = tap(Collection::make('articles')->dated(true))->save(); + + $collection->entryBlueprint()->setContents([ + 'fields' => [ + ['handle' => 'date', 'field' => ['type' => 'date', 'time_enabled' => true]], + ], + ])->save(); + + $entry = Entry::make()->id('foo')->collection('articles')->date('2025-01-01-1200'); + $entry->save(); + $this->assertEquals('2025-01-01T12:00:00+00:00', $entry->date()->toIso8601String()); + + $this->migrateDatesToUtc(); + + $entry = Entry::find($entry->id()); + $this->assertEquals('2025-01-01T17:00:00+00:00', $entry->date()->toIso8601String()); + $this->assertStringContainsString('2025-01-01-1700.foo.md', $entry->buildPath()); + } + + #[Test] + public function it_converts_entry_date_field_in_entries_when_app_timezone_is_utc() + { + config()->set('app.timezone', 'UTC'); + date_default_timezone_set('UTC'); + + $this->it_converts_entry_date_field_in_entries(); + } + + #[Test] + #[DataProvider('dateFieldsProvider')] + public function it_converts_date_fields_in_terms(string $fieldHandle, array $field, $original, $expected) + { + $taxonomy = tap(Taxonomy::make('tags'))->save(); + $taxonomy->termBlueprint()->setContents(['fields' => [$field]])->save(); + + $term = Term::make()->taxonomy('tags')->data([$fieldHandle => $original]); + $term->save(); + + $this->migrateDatesToUtc(); + + $this->assertEquals($expected, $term->fresh()->get($fieldHandle)); + } + + #[Test] + #[DataProvider('dateFieldsProvider')] + public function it_converts_date_fields_in_globals(string $fieldHandle, array $field, $original, $expected) + { + $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->migrateDatesToUtc(); + + $globalSet = GlobalSet::find('settings'); + + $this->assertEquals($expected, $globalSet->inDefaultSite()->get($fieldHandle)); + } + + #[Test] + #[DataProvider('dateFieldsProvider')] + public function it_converts_date_fields_in_users(string $fieldHandle, array $field, $original, $expected) + { + User::blueprint()->setContents(['fields' => [$field]])->save(); + + $user = User::make()->data([$fieldHandle => $original]); + $user->save(); + + $this->migrateDatesToUtc(); + + $this->assertEquals($expected, $user->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 05:00', + ], + '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 05:00', 'end' => '2025-01-07 05:00'], + ], + 'Date range, but stored as a single date' => [ + 'date_field', + ['handle' => 'date_field', 'field' => ['type' => 'date', 'mode' => 'range']], + '2025-01-01', + ['start' => '2025-01-01 05:00', 'end' => '2025-01-01 05:00'], + ], + '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 05:00', 'end' => '2025-01-07 05:00'], + ], + ], + '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']], + ]]], + [['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 05:00', 'end' => '2025-01-07 05:00']]], + ], + 'Replicator field with nested date fields' => [ + 'replicator_field', + ['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']], + ]], + ]], + ]]], + [['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 05:00', 'end' => '2025-01-07 05:00']]], + ], + 'Bard field with nested date fields' => [ + 'bard_field', + ['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']], + ]], + ]], + ]]], + [['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 05:00', 'end' => '2025-01-07 05:00'], + ]]]], + ], + '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']], + ]]], + ]]], + [['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 05:00', 'end' => '2025-01-07 05:00'], + ]]], + ], + ]; + } + + private function migrateDatesToUtc(): void + { + $this + ->artisan('statamic:migrate-dates-to-utc', [ + 'timezone' => 'America/New_York', + ]) + ->expectsQuestion('Do you want to continue', true); + } +} diff --git a/tests/Data/Entries/EntryTest.php b/tests/Data/Entries/EntryTest.php index f99532f921b..e3e23395b85 100644 --- a/tests/Data/Entries/EntryTest.php +++ b/tests/Data/Entries/EntryTest.php @@ -930,14 +930,6 @@ public function it_gets_dates_for_dated_collection_entries( ) { Carbon::setTestNow(Carbon::parse('2015-09-24 13:45:23')); - $collection = tap(Facades\Collection::make('test')->dated(true))->save(); - - $entry = (new Entry)->collection($collection)->slug('foo'); - - if ($setDate) { - $entry->date($setDate); - } - $fields = []; if ($enableTimeInBlueprint) { @@ -949,6 +941,14 @@ public function it_gets_dates_for_dated_collection_entries( 'test' => $blueprint->setHandle('test'), ])); + $collection = tap(Facades\Collection::make('test')->dated(true))->save(); + + $entry = (new Entry)->collection($collection)->slug('foo'); + + if ($setDate) { + $entry->date($setDate); + } + $this->assertTrue($entry->hasDate()); $this->assertEquals($expectedDate, $entry->date()->format('Y-m-d H:i:s')); $this->assertEquals($expectedToHaveTime, $entry->hasTime()); @@ -983,6 +983,50 @@ public static function dateCollectionEntriesProvider() 'datetime with seconds set, time disabled' => ['2023-04-19-142512', false, null, '2023-04-19 00:00:00', false, false, '2023-04-19.foo'], 'datetime with seconds set, time disabled, seconds enabled' => ['2023-04-19-142512', false, true, '2023-04-19 00:00:00', false, false, '2023-04-19.foo'], // Time is disabled, so seconds should be disabled too. + + 'date explicitly set in another timezone' => [Carbon::parse('2025-03-07 22:00', 'America/New_York'), false, false, '2025-03-08 03:00:00', true, false, '2025-03-08-0300.foo'], // Passing in a carbon instance will adjust from its timezone + ]; + } + + #[Test] + #[DataProvider('dateCollectionEntriesAsStringProvider')] + public function it_gets_dates_for_dated_collection_entries_when_passed_as_string( + $appTimezone, + $date, + $expectedDate + ) { + config(['app.timezone' => $appTimezone]); + + Carbon::setTestNow(Carbon::parse('2025-02-02 13:45:23')); + + $blueprint = Blueprint::makeFromFields([ + 'date' => ['type' => 'date', 'time_enabled' => true, 'time_seconds_enabled' => true], + ]); + BlueprintRepository::shouldReceive('in')->with('collections/test')->andReturn(collect([ + 'test' => $blueprint->setHandle('test'), + ])); + + $collection = tap(Facades\Collection::make('test')->dated(true))->save(); + + $entry = (new Entry)->collection($collection)->slug('foo')->date($date); + + $this->assertEquals($expectedDate, $entry->date()->toIso8601String()); + } + + public static function dateCollectionEntriesAsStringProvider() + { + // The date is treated as UTC regardless of the timezone so no conversion should be done. + return [ + 'utc' => [ + 'UTC', + '2023-02-20-033513', + '2023-02-20T03:35:13+00:00', + ], + 'not utc' => [ + 'America/New_York', + '2023-02-20-033513', + '2023-02-20T03:35:13+00:00', + ], ]; } diff --git a/tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php b/tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php index ddd0da59c1c..c34394aa1ca 100644 --- a/tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php +++ b/tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php @@ -28,6 +28,9 @@ 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 + // Set the to string format so can see it uses that rather than a coincidence. // But reset it afterwards. $originalFormat = Carbon::getToStringFormat(); @@ -51,8 +54,8 @@ public function it_gets_dates() GQL; $this->assertGqlEntryHas($query, [ - 'default' => '2017-12-25 1:29pm', - 'formatted' => '1514208540', + 'default' => '2017-12-25 6:29pm', + 'formatted' => '1514226540', 'undefined' => null, ]); diff --git a/tests/Fieldtypes/DateTest.php b/tests/Fieldtypes/DateTest.php index 7d6f358bc0f..8d38f3dfd37 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($timezone, $config, $value, $expected) { + config()->set('app.timezone', $timezone); + $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 in a different timezone' => [ + 'America/New_York', // -5000 + [], + '2012-01-04', + '2012 Jan 04 05:00:00', + ], // The time and seconds configs are important, otherwise // 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 in a different timezone' => [ + 'America/New_York', // -5000 + ['time_enabled' => true], + '2012-01-04 15:32', + '2012 Jan 04 20:32:00', + ], ]; } @@ -115,8 +134,10 @@ public function it_augments_a_null_range() #[Test] #[DataProvider('processProvider')] - public function it_processes_on_save($config, $value, $expected) + public function it_processes_on_save($timezone, $config, $value, $expected) { + config()->set('app.timezone', $timezone); + $this->assertSame($expected, $this->fieldtype($config)->process($value)); } @@ -124,64 +145,76 @@ public static function processProvider() { return [ 'null' => [ + 'UTC', [], null, null, ], 'object with nulls' => [ + 'UTC', [], ['date' => null, 'time' => null], null, ], 'object with missing time' => [ + 'UTC', [], ['date' => null], null, ], 'date with default format' => [ + 'UTC', [], - ['date' => '2012-08-29', 'time' => null], - '2012-08-29', + ['date' => '2012-08-29', 'time' => '00:00'], + '2012-08-29 00:00', ], 'date with custom format' => [ - ['format' => 'Y--m--d'], - ['date' => '2012-08-29', 'time' => null], - '2012--08--29', + 'UTC', + ['format' => 'Y--m--d H/i'], + ['date' => '2012-08-29', 'time' => '00:00'], + '2012--08--29 00/00', + ], + 'date in a different timezone' => [ + 'America/New_York', // -4000 + [], + ['date' => '2012-08-29', 'time' => '00:00'], + '2012-08-28 20:00', ], 'date with missing time' => [ + 'UTC', [], ['date' => '2012-08-29'], - '2012-08-29', + '2012-08-29 00:00', ], 'date with time' => [ + 'UTC', ['time_enabled' => true], ['date' => '2012-08-29', 'time' => '13:43'], '2012-08-29 13:43', ], - 'date with time and custom format' => [ - ['time_enabled' => true, 'format' => 'Y--m--d H:i'], - ['date' => '2012-08-29', 'time' => '13:43'], - '2012--08--29 13:43', - ], 'null range' => [ + 'UTC', ['mode' => 'range'], - ['date' => null, 'time' => null], + ['start' => null, 'end' => null], null, ], 'range with default format' => [ + 'UTC', ['mode' => 'range'], - ['date' => ['start' => '2012-08-29', 'end' => '2013-09-27'], 'time' => null], - ['start' => '2012-08-29', 'end' => '2013-09-27'], + ['start' => ['date' => '2012-08-29', 'time' => '00:00'], 'end' => ['date' => '2013-09-27', 'time' => '23:59']], + ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 23:59'], ], 'range with custom format' => [ - ['mode' => 'range', 'format' => 'Y--m--d'], - ['date' => ['start' => '2012-08-29', 'end' => '2013-09-27'], 'time' => null], - ['start' => '2012--08--29', 'end' => '2013--09--27'], + 'UTC', + ['mode' => 'range', 'format' => 'Y--m--d H/i'], + ['start' => ['date' => '2012-08-29', 'time' => '00:00'], 'end' => ['date' => '2013-09-27', 'time' => '23:59']], + ['start' => '2012--08--29 00/00', 'end' => '2013--09--27 23/59'], ], - 'range with format containing time has end date at end of day' => [ - ['mode' => 'range', 'format' => 'Y-m-d H:i:s'], - ['date' => ['start' => '2012-08-29', 'end' => '2013-09-27'], 'time' => null], - ['start' => '2012-08-29 00:00:00', 'end' => '2013-09-27 23:59:59'], + 'range in a different timezone' => [ + 'America/New_York', // -4000 + ['mode' => 'range'], + ['start' => ['date' => '2012-08-29', 'time' => '00:00'], 'end' => ['date' => '2013-09-27', 'time' => '23:59']], + ['start' => '2012-08-28 20:00', 'end' => '2013-09-27 19:59'], ], ]; } @@ -195,18 +228,20 @@ public function it_saves_date_as_integer_if_format_results_in_a_number() #[Test] public function it_saves_ranges_as_integers_if_format_results_in_a_number() { - $fieldtype = $this->fieldtype(['mode' => 'range', 'format' => 'Ymd']); + $fieldtype = $this->fieldtype(['mode' => 'range', 'format' => 'YmdHi']); $this->assertSame( - ['start' => 20120829, 'end' => 20130927], - $fieldtype->process(['date' => ['start' => '2012-08-29', 'end' => '2013-09-27']]) + ['start' => 201208290000, 'end' => 201309272359], + $fieldtype->process(['start' => ['date' => '2012-08-29', 'time' => '00:00'], 'end' => ['date' => '2013-09-27', 'time' => '23:59']]) ); } #[Test] #[DataProvider('preProcessProvider')] - public function it_preprocesses($config, $value, $expected) + public function it_preprocesses($timezone, $config, $value, $expected) { + config()->set('app.timezone', $timezone); + $this->assertSame($expected, $this->fieldtype($config)->preProcess($value)); } @@ -214,95 +249,113 @@ public static function preProcessProvider() { return [ 'null' => [ + 'UTC', [], null, ['date' => null, 'time' => null], ], 'now' => [ + 'UTC', [], 'now', // this would happen if the value was null, but default was "now" - ['date' => '2010-12-25', 'time' => null], // current date + ['date' => '2010-12-25', 'time' => '13:43'], // current date ], - 'now, with time enabled' => [ - ['time_enabled' => true], - 'now', // this would happen if the value was null, but default was "now" - ['date' => '2010-12-25', 'time' => 'now'], // current datetime - time needs to be localized on the client side + 'date without time' => [ + 'UTC', + [], + '2012-08-29', + ['date' => '2012-08-29', 'time' => '00:00'], ], 'date with default format' => [ + 'UTC', [], - '2012-08-29', - ['date' => '2012-08-29', 'time' => null], + '2012-08-29 00:00', + ['date' => '2012-08-29', 'time' => '00:00'], ], 'date with custom format' => [ - ['format' => 'Y--m--d'], - '2012--08--29', - ['date' => '2012-08-29', 'time' => null], + 'UTC', + ['format' => 'Y--m--d H/i'], + '2012--08--29 00/00', + ['date' => '2012-08-29', 'time' => '00:00'], + ], + 'date in a different timezone' => [ + 'America/New_York', // -0400 + [], + '2012-08-29 00:00', + ['date' => '2012-08-29', 'time' => '04:00'], ], 'date with time' => [ + 'UTC', ['time_enabled' => true], '2012-08-29 13:43', ['date' => '2012-08-29', 'time' => '13:43'], ], - 'date with time and custom format' => [ - ['time_enabled' => true, 'format' => 'Y--m--d H:i'], - '2012--08--29 13:43', - ['date' => '2012-08-29', 'time' => '13:43'], + 'date with time in a different timezone' => [ + 'America/New_York', // -0400 + ['time_enabled' => true], + '2012-08-29 13:43', + ['date' => '2012-08-29', 'time' => '17:43'], ], 'null range' => [ + 'UTC', ['mode' => 'range'], null, - ['date' => null, 'time' => null], - ], - 'null range when required with boolean' => [ - ['mode' => 'range', 'required' => true], - null, - ['date' => ['start' => '2010-12-25', 'end' => '2010-12-25'], 'time' => null], - ], - 'null range when required with validation' => [ - ['mode' => 'range', 'validate' => ['required']], null, - ['date' => ['start' => '2010-12-25', 'end' => '2010-12-25'], 'time' => null], ], 'range with default format' => [ + 'UTC', ['mode' => 'range'], - ['start' => '2012-08-29', 'end' => '2013-09-27'], - ['date' => ['start' => '2012-08-29', 'end' => '2013-09-27'], 'time' => null], + ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 23:59'], + ['start' => ['date' => '2012-08-29', 'time' => '00:00'], 'end' => ['date' => '2013-09-27', 'time' => '23:59']], ], 'range with custom format' => [ - ['mode' => 'range', 'format' => 'Y--m--d'], - ['start' => '2012--08--29', 'end' => '2013--09--27'], - ['date' => ['start' => '2012-08-29', 'end' => '2013-09-27'], 'time' => null], + 'UTC', + ['mode' => 'range', 'format' => 'Y--m--d H/i'], + ['start' => '2012--08--29 00/00', 'end' => '2013--09--27 23/59'], + ['start' => ['date' => '2012-08-29', 'time' => '00:00'], 'end' => ['date' => '2013-09-27', 'time' => '23:59']], + ], + 'range in a different timezone' => [ + 'America/New_York', // -4000 + ['mode' => 'range'], + ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 23:59'], + ['start' => ['date' => '2012-08-29', 'time' => '04:00'], 'end' => ['date' => '2013-09-28', 'time' => '03:59']], ], 'range where single date has been provided' => [ + 'UTC', // e.g. If it was once a non-range field. // Use the single date as both the start and end dates. ['mode' => 'range'], '2012-08-29', - ['date' => ['start' => '2012-08-29', 'end' => '2012-08-29'], 'time' => null], + ['start' => ['date' => '2012-08-29', 'time' => '00:00'], 'end' => ['date' => '2012-08-29', 'time' => '23:59']], ], 'range where single date has been provided with custom format' => [ + 'UTC', ['mode' => 'range', 'format' => 'Y--m--d'], '2012--08--29', - ['date' => ['start' => '2012-08-29', 'end' => '2012-08-29'], 'time' => null], + ['start' => ['date' => '2012-08-29', 'time' => '00:00'], 'end' => ['date' => '2012-08-29', 'time' => '23:59']], ], 'date where range has been provided' => [ + 'UTC', // e.g. If it was once a range field. Use the start date. [], - ['start' => '2012-08-29', 'end' => '2013-09-27'], - ['date' => '2012-08-29', 'time' => null], + ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 23:59'], + ['date' => '2012-08-29', 'time' => '00:00'], ], 'date where range has been provided with custom format' => [ - ['format' => 'Y--m--d'], - ['start' => '2012--08--29', 'end' => '2013--09--27'], - ['date' => '2012-08-29', 'time' => null], + 'UTC', + ['format' => 'Y--m--d H/i'], + ['start' => '2012--08--29 00/00', 'end' => '2013--09--27 23/59'], + ['date' => '2012-08-29', 'time' => '00:00'], ], ]; } #[Test] #[DataProvider('preProcessIndexProvider')] - public function it_preprocesses_for_index($config, $value, $expected) + public function it_preprocesses_for_index($timezone, $config, $value, $expected) { + config()->set('app.timezone', $timezone); + // Show that the date format from the preference is being used, and // that the fall back would have been the configured date format. config(['statamic.cp.date_format' => 'custom']); @@ -315,72 +368,103 @@ public static function preProcessIndexProvider() { return [ 'null' => [ + 'UTC', [], null, null, ], 'date with default format' => [ + 'UTC', [], - '2012-08-29', - '2012/08/29', + '2012-08-29 00:00', + ['date' => '2012-08-29', 'time' => '00:00', 'mode' => 'single', 'display_format' => 'YYYY/MM/DD'], ], 'date with custom format' => [ - ['format' => 'Y--m--d'], - '2012--08--29', - '2012/08/29', + 'UTC', + ['format' => 'Y--m--d H/i'], + '2012--08--29 00/00', + ['date' => '2012-08-29', 'time' => '00:00', 'mode' => 'single', 'display_format' => 'YYYY/MM/DD'], + ], + 'date in a different timezone' => [ + 'America/New_York', // -0400 + [], + '2012-08-29 00:00', + ['date' => '2012-08-29', 'time' => '04:00', 'mode' => 'single', 'display_format' => 'YYYY/MM/DD'], ], 'date with time' => [ + 'UTC', ['time_enabled' => true], '2012-08-29 13:43', - '2012/08/29 13:43', + ['date' => '2012-08-29', 'time' => '13:43', 'mode' => 'single', 'display_format' => 'YYYY/MM/DD HH:mm'], ], 'date with time and custom format' => [ + 'UTC', ['time_enabled' => true, 'format' => 'Y--m--d H:i'], '2012--08--29 13:43', - '2012/08/29 13:43', + ['date' => '2012-08-29', 'time' => '13:43', 'mode' => 'single', 'display_format' => 'YYYY/MM/DD HH:mm'], + ], + 'date with time in a different timezone' => [ + 'America/New_York', // -0400 + ['time_enabled' => true], + '2012-08-29 13:43', + ['date' => '2012-08-29', 'time' => '17:43', 'mode' => 'single', 'display_format' => 'YYYY/MM/DD HH:mm'], ], 'null range' => [ + 'UTC', ['mode' => 'range'], null, null, ], 'range with default format' => [ + 'UTC', ['mode' => 'range'], - ['start' => '2012-08-29', 'end' => '2013-09-27'], - '2012/08/29 - 2013/09/27', + ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 00:00'], + ['start' => ['date' => '2012-08-29', 'time' => '00:00'], 'end' => ['date' => '2013-09-27', 'time' => '00:00'], 'mode' => 'range', 'display_format' => 'YYYY/MM/DD'], ], 'range with custom format' => [ - ['mode' => 'range', 'format' => 'Y--m--d'], - ['start' => '2012--08--29', 'end' => '2013--09--27'], - '2012/08/29 - 2013/09/27', + 'UTC', + ['mode' => 'range', 'format' => 'Y--m--d H/i'], + ['start' => '2012--08--29 00/00', 'end' => '2013--09--27 00/00'], + ['start' => ['date' => '2012-08-29', 'time' => '00:00'], 'end' => ['date' => '2013-09-27', 'time' => '00:00'], 'mode' => 'range', 'display_format' => 'YYYY/MM/DD'], + ], + 'range in a different timezone' => [ + 'America/New_York', // -4000 + ['mode' => 'range'], + ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 00:00'], + ['start' => ['date' => '2012-08-29', 'time' => '04:00'], 'end' => ['date' => '2013-09-27', 'time' => '04:00'], 'mode' => 'range', 'display_format' => 'YYYY/MM/DD'], ], 'range where single date has been provided' => [ // e.g. If it was once a non-range field. // Use the single date as both the start and end dates. + 'UTC', ['mode' => 'range'], '2012-08-29', - '2012/08/29 - 2012/08/29', + ['start' => ['date' => '2012-08-29', 'time' => '00:00'], 'end' => ['date' => '2012-08-29', 'time' => '00:00'], 'mode' => 'range', 'display_format' => 'YYYY/MM/DD'], ], 'range where single date has been provided with custom format' => [ - ['mode' => 'range', 'format' => 'Y--m--d'], - '2012--08--29', - '2012/08/29 - 2012/08/29', + 'UTC', + ['mode' => 'range', 'format' => 'Y--m--d H/i'], + '2012--08--29 00/00', + ['start' => ['date' => '2012-08-29', 'time' => '00:00'], 'end' => ['date' => '2012-08-29', 'time' => '00:00'], 'mode' => 'range', 'display_format' => 'YYYY/MM/DD'], ], 'date where range has been provided' => [ // e.g. If it was once a range field. Use the start date. + 'UTC', [], - ['start' => '2012-08-29', 'end' => '2013-09-27'], - '2012/08/29', + ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 00:00'], + ['date' => '2012-08-29', 'time' => '00:00', 'mode' => 'single', 'display_format' => 'YYYY/MM/DD'], ], 'date where range has been provided with custom format' => [ - ['format' => 'Y--m--d'], - ['start' => '2012--08--29', 'end' => '2013--09--27'], - '2012/08/29', + 'UTC', + ['format' => 'Y--m--d H/i'], + ['start' => '2012--08--29 00/00', 'end' => '2013--09--27 00/00'], + ['date' => '2012-08-29', 'time' => '00:00', 'mode' => 'single', 'display_format' => 'YYYY/MM/DD'], ], 'range where time has been enabled' => [ + 'UTC', ['mode' => 'range', 'time_enabled' => true], // enabling time should have no effect. - ['start' => '2012-08-29', 'end' => '2013-09-27'], - '2012/08/29 - 2013/09/27', + ['start' => '2012-08-29 00:00', 'end' => '2013-09-27 00:00'], + ['start' => ['date' => '2012-08-29', 'time' => '00:00'], 'end' => ['date' => '2013-09-27', 'time' => '00:00'], 'mode' => 'range', 'display_format' => 'YYYY/MM/DD'], ], ]; } diff --git a/tests/Modifiers/FormatTest.php b/tests/Modifiers/FormatTest.php new file mode 100644 index 00000000000..78bc8d7c7c8 --- /dev/null +++ b/tests/Modifiers/FormatTest.php @@ -0,0 +1,37 @@ +set('statamic.system.display_timezone', 'Europe/Berlin'); // +1 hour + } + + #[Test] + public function it_formats_date() + { + $this->assertSame('1st January 2025 3:45pm', $this->modify(Carbon::parse('2025-01-01 15:45'), 'jS F Y g:ia')); + } + + #[Test] + public function it_formats_date_and_outputs_in_display_timezone() + { + config()->set('statamic.system.localize_dates_in_modifiers', true); + + $this->assertSame('1st January 2025 4:45pm', $this->modify(Carbon::parse('2025-01-01 15:45'), 'jS F Y g:ia')); + } + + public function modify($value, $format) + { + return Modify::value($value)->format($format)->fetch(); + } +} diff --git a/tests/Modifiers/FormatTranslatedTest.php b/tests/Modifiers/FormatTranslatedTest.php new file mode 100644 index 00000000000..10a2d8577de --- /dev/null +++ b/tests/Modifiers/FormatTranslatedTest.php @@ -0,0 +1,46 @@ +set('statamic.system.display_timezone', 'Europe/Berlin'); // +1 hour + } + + public function tearDown(): void + { + parent::tearDown(); + + Carbon::setLocale('en'); + } + + #[Test] + public function it_formats_date() + { + $this->assertSame('Mittwoch 1 Januar 2025, 15:45', $this->modify(Carbon::parse('2025-01-01 15:45'), 'l j F Y, H:i')); + } + + #[Test] + public function it_formats_date_and_outputs_in_display_timezone() + { + config()->set('statamic.system.localize_dates_in_modifiers', true); + + $this->assertSame('Mittwoch 1 Januar 2025, 16:45', $this->modify(Carbon::parse('2025-01-01 15:45'), 'l j F Y, H:i')); + } + + public function modify($value, $format) + { + return Modify::value($value)->formatTranslated($format)->fetch(); + } +} diff --git a/tests/Modifiers/IsoFormatTest.php b/tests/Modifiers/IsoFormatTest.php new file mode 100644 index 00000000000..5f78c66a5e9 --- /dev/null +++ b/tests/Modifiers/IsoFormatTest.php @@ -0,0 +1,37 @@ +set('statamic.system.display_timezone', 'Europe/Berlin'); // +1 hour + } + + #[Test] + public function it_formats_date() + { + $this->assertSame('2025.01.01 15:45', $this->modify(Carbon::parse('2025-01-01 15:45'), 'YYYY.MM.DD HH:mm')); + } + + #[Test] + public function it_formats_date_and_outputs_in_display_timezone() + { + config()->set('statamic.system.localize_dates_in_modifiers', true); + + $this->assertSame('2025.01.01 16:45', $this->modify(Carbon::parse('2025-01-01 15:45'), 'YYYY.MM.DD HH:mm')); + } + + public function modify($value, $format) + { + return Modify::value($value)->isoFormat($format)->fetch(); + } +} diff --git a/tests/Modifiers/ModifyDateTest.php b/tests/Modifiers/ModifyDateTest.php new file mode 100644 index 00000000000..1185afd6a10 --- /dev/null +++ b/tests/Modifiers/ModifyDateTest.php @@ -0,0 +1,22 @@ +assertEquals($this->modify(Carbon::parse('2025-01-01'), '+2 months')->format('Y-m-d'), '2025-03-01'); + } + + public function modify($value, $modify) + { + return Modify::value($value)->modifyDate($modify)->fetch(); + } +} diff --git a/tests/Modifiers/TimezoneTest.php b/tests/Modifiers/TimezoneTest.php new file mode 100644 index 00000000000..01e694c939d --- /dev/null +++ b/tests/Modifiers/TimezoneTest.php @@ -0,0 +1,25 @@ +assertEquals( + $this->modify(Carbon::parse('2025-01-01 15:45'), 'Europe/Berlin')->format('Y-m-d H:i'), + '2025-01-01 16:45' + ); + } + + public function modify($value, $timezone) + { + return Modify::value($value)->timezone($timezone)->fetch(); + } +} diff --git a/tests/Stache/Stores/EntriesStoreTest.php b/tests/Stache/Stores/EntriesStoreTest.php index 0d2f241e1cb..705a70f3c75 100644 --- a/tests/Stache/Stores/EntriesStoreTest.php +++ b/tests/Stache/Stores/EntriesStoreTest.php @@ -5,6 +5,7 @@ use Facades\Statamic\Stache\Traverser; use Illuminate\Support\Carbon; use Mockery; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\Entries\Entry; use Statamic\Facades; @@ -103,6 +104,33 @@ public function it_makes_entry_instances_from_files() $this->assertTrue($item->published()); } + #[Test] + #[DataProvider('timezoneProvider')] + public function it_makes_entry_instances_from_files_with_different_timezone($filename, $expectedDate) + { + config(['app.timezone' => 'America/New_York']); + + Facades\Collection::shouldReceive('findByHandle')->with('blog')->andReturn( + (new \Statamic\Entries\Collection)->handle('blog')->dated(true) + ); + + $item = $this->parent->store('blog')->makeItemFromFile( + Path::tidy($this->directory).'/blog/'.$filename, + "id: 123\ntitle: Example\nfoo: bar" + ); + + $this->assertEquals($expectedDate, $item->date()->format('Y-m-d H:i:s')); + } + + public static function timezoneProvider() + { + return [ + 'midnight' => ['2017-01-02.my-post.md', '2017-01-02 05:00:00'], + '10pm' => ['2017-01-02-2200.my-post.md', '2017-01-03 03:00:00'], + '10pm with seconds' => ['2017-01-02-220013.my-post.md', '2017-01-03 03:00:13'], + ]; + } + #[Test] public function if_slugs_are_not_required_the_filename_still_becomes_the_slug() { diff --git a/tests/UpdateScripts/AddTimezoneConfigOptionsTest.php b/tests/UpdateScripts/AddTimezoneConfigOptionsTest.php new file mode 100644 index 00000000000..9c1c38e67a5 --- /dev/null +++ b/tests/UpdateScripts/AddTimezoneConfigOptionsTest.php @@ -0,0 +1,51 @@ +assertUpdateScriptRegistered(AddTimezoneConfigOptions::class); + } + + #[Test] + public function it_appends_timezone_option_to_system_config() + { + File::ensureDirectoryExists(app()->configPath('statamic')); + + File::put(app()->configPath('statamic/system.php'), <<<'EOT' + 'this', + + 'date_format' => 'F jS, Y', + + 'below' => 'that', + +]; +EOT + ); + + $this->runUpdateScript(AddTimezoneConfigOptions::class); + + $systemConfig = File::get(app()->configPath('statamic/system.php')); + + $this->assertStringContainsString("'above' => 'this',", $systemConfig); + $this->assertStringContainsString("'date_format' => 'F jS, Y',", $systemConfig); + $this->assertStringContainsString("'display_timezone' => null,", $systemConfig); + $this->assertStringContainsString("'localize_dates_in_modifiers' => true,", $systemConfig); + $this->assertStringContainsString("'below' => 'that',", $systemConfig); + } +}