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);
+ }
+}