diff --git a/src/frontend/fields/date-field.vue b/src/frontend/fields/date-field.vue
index d78e25d..2150ed4 100644
--- a/src/frontend/fields/date-field.vue
+++ b/src/frontend/fields/date-field.vue
@@ -47,6 +47,7 @@ import '@vuepic/vue-datepicker/dist/main.css';
import type { FieldSpec } from '../../types';
import { useModelStore, useSharedStore } from '../store/index';
import { commonProps } from '../shared/helpers';
+import { normalizeDateForStorage } from '../shared/helpers';
const props = defineProps({
...commonProps,
@@ -74,7 +75,8 @@ model.$subscribe(() => {
const onUpdate = (date: Date) => {
if (props.isReadOnly) return;
- model.setField(fieldPath.value, date);
+ const valueToStore = normalizeDateForStorage(date, field.value.hasTimePicker ?? false);
+ model.setField(fieldPath.value, valueToStore);
};
const errors = computed(() => shared.errorMessages(fieldPath.value));
diff --git a/src/frontend/fields/date-range-field.story.vue b/src/frontend/fields/date-range-field.story.vue
index d253e6f..b3db71e 100644
--- a/src/frontend/fields/date-range-field.story.vue
+++ b/src/frontend/fields/date-range-field.story.vue
@@ -10,6 +10,11 @@
+
+
+
+
+
@@ -39,6 +44,11 @@ const objectModel = {
window: '2027-01-08T07:30:00.000Z|2027-01-15T07:30:00.000Z',
};
+const objectModelWithDefinedEndOfDay = {
+ ...objectModel,
+ window: '2027-01-08T07:30:00.000Z|2027-01-15T23:59:59.999Z',
+};
+
const readonlyModel = {
...objectModel,
window: '2025-07-08T07:30:00.000Z|2025-07-15T07:30:00.000Z',
diff --git a/src/frontend/fields/date-range-field.vue b/src/frontend/fields/date-range-field.vue
index e79b122..71684b7 100644
--- a/src/frontend/fields/date-range-field.vue
+++ b/src/frontend/fields/date-range-field.vue
@@ -43,7 +43,7 @@ import '@vuepic/vue-datepicker/dist/main.css';
import type { FieldSpec } from '../../types';
import { useModelStore, useSharedStore } from '../store/index';
-import { commonProps } from '../shared/helpers';
+import { commonProps, normalizeDateForStorage, parseIsoDateForDisplay } from '../shared/helpers';
type WidgetValue = [Date | null, Date | null];
@@ -74,12 +74,10 @@ const getValueFromStore = (): WidgetValue => {
const startStr = parts[0]?.trim();
const endStr = parts[1]?.trim();
- const start = startStr ? new Date(startStr) : null;
- const end = endStr ? new Date(endStr) : null;
-
- // Validate dates - if invalid, return null
- const startDate = start && !isNaN(start.getTime()) ? start : null;
- const endDate = end && !isNaN(end.getTime()) ? end : null;
+ // Parse using calendar date to avoid timezone display issues
+ // (T23:59:59.999Z shows as next day in UTC+; T00:00:00.000Z as previous day in UTC-)
+ const startDate = startStr ? parseIsoDateForDisplay(startStr) : null;
+ const endDate = endStr ? parseIsoDateForDisplay(endStr) : null;
return [startDate, endDate];
};
@@ -100,7 +98,11 @@ const onUpdate = (date: WidgetValue) => {
return;
}
- const fresh = `${date[0].toISOString()}|${date[1].toISOString()}`;
+ // Date range is always date-only (no time picker) - start at T00:00:00.000Z
+ const start = normalizeDateForStorage(date[0], false);
+ // End date runs until the very end of the day at T23:59:59.999Z
+ const normalizedEnd = normalizeDateForStorage(date[1], false, true);
+ const fresh = `${start.toISOString()}|${normalizedEnd.toISOString()}`;
model.setField(fieldPath.value, fresh);
};
diff --git a/src/frontend/index.story.md b/src/frontend/index.story.md
index 8920f2c..ab3cc1a 100644
--- a/src/frontend/index.story.md
+++ b/src/frontend/index.story.md
@@ -214,8 +214,10 @@ DateField with time picker
## dateRange
-A date range picker that renders a [DateRangeField](#). This field has no special keys.
-The two dates are stored in the model with the current time as UTC time.
+A date-only range picker that renders a [DateRangeField](#). This field has no special
+keys and no time picker. The two dates are stored as pipe-separated ISO strings: the
+start at T00:00:00.000Z (midnight UTC) and the end at T23:59:59.999Z (end of day UTC) so
+the range includes the full last day.
example:
diff --git a/src/frontend/shared/helpers.ts b/src/frontend/shared/helpers.ts
index 4b5d29c..4e1a508 100644
--- a/src/frontend/shared/helpers.ts
+++ b/src/frontend/shared/helpers.ts
@@ -185,3 +185,50 @@ export const getCampaignStatus = (
if (currentTime > windowEnd) return 'Completed';
return 'Live';
};
+
+/**
+ * Normalizes a date for storage when the date field is used without a time picker.
+ *
+ * When endOfDay is false: stores as T00:00:00.000Z on the selected date.
+ * - User selects today → visible instantly from midnight UTC
+ * - User selects any other day → shown from T00:00:00.000Z on that date
+ *
+ * When endOfDay is true (date-range end dates): stores as T23:59:59.999Z so the
+ * range includes the full last day; campaign stays Live through end of that day.
+ *
+ * @param date - The date selected by the user
+ * @param hasTimePicker - Whether the field has time picker enabled
+ * @param endOfDay - When true, store as T23:59:59.999Z for date-range end dates
+ * @returns The date to store (Date object that serializes to ISO string)
+ */
+export function normalizeDateForStorage(
+ date: Date,
+ hasTimePicker: boolean,
+ endOfDay = false,
+): Date {
+ if (hasTimePicker) {
+ return date;
+ }
+ const year = date.getFullYear();
+ const month = date.getMonth();
+ const day = date.getDate();
+ if (endOfDay) {
+ return new Date(Date.UTC(year, month, day, 23, 59, 59, 999));
+ }
+ return new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
+}
+
+/**
+ * Parses an ISO date string (e.g. from date-range storage) to a local Date for picker display.
+ * Extracts the calendar date (Y-M-D) and creates noon local - avoids timezone shift
+ * where T00:00:00.000Z or T23:59:59.999Z would display as the wrong day.
+ */
+export function parseIsoDateForDisplay(iso: string): Date | null {
+ const match = iso.trim().match(/^(\d{4})-(\d{2})-(\d{2})/);
+ if (!match) return null;
+ const year = parseInt(match[1], 10);
+ const month = parseInt(match[2], 10) - 1;
+ const day = parseInt(match[3], 10);
+ const d = new Date(year, month, day, 12, 0, 0, 0);
+ return isNaN(d.getTime()) ? null : d;
+}
diff --git a/tests/unit/dateField.spec.ts b/tests/unit/dateField.spec.ts
new file mode 100644
index 0000000..f0e0b94
--- /dev/null
+++ b/tests/unit/dateField.spec.ts
@@ -0,0 +1,66 @@
+import { test, expect } from '@playwright/test';
+import { normalizeDateForStorage } from '../../src/frontend/shared/helpers';
+
+test.describe('Date Field storage normalization', () => {
+ test.describe('when user selects today (date-only, no time picker)', () => {
+ test('stores value as T00:00:00.000Z on the date selected for instant campaign visibility', () => {
+ // User selects today - e.g. 2025-03-02 in local timezone
+ const selectedDate = new Date(2025, 2, 2, 14, 30, 0, 0); // March 2, 2025 2:30 PM local
+
+ const result = normalizeDateForStorage(selectedDate, false);
+
+ expect(result.toISOString()).toBe('2025-03-02T00:00:00.000Z');
+ });
+ });
+
+ test.describe('when user selects any other day (date-only, no time picker)', () => {
+ test('stores value as T00:00:00.000Z on the date selected so campaign is shown from that time', () => {
+ // User selects a future date - e.g. 2025-03-15
+ const selectedDate = new Date(2025, 2, 15, 9, 0, 0, 0); // March 15, 2025 9:00 AM local
+
+ const result = normalizeDateForStorage(selectedDate, false);
+
+ expect(result.toISOString()).toBe('2025-03-15T00:00:00.000Z');
+ });
+
+ test('stores value as T00:00:00.000Z for a past date', () => {
+ const selectedDate = new Date(2024, 0, 1, 23, 59, 59, 999); // Jan 1, 2024 late local
+
+ const result = normalizeDateForStorage(selectedDate, false);
+
+ expect(result.toISOString()).toBe('2024-01-01T00:00:00.000Z');
+ });
+ });
+
+ test.describe('when date picker is at midnight local time', () => {
+ test('still normalizes to T00:00:00.000Z for the selected date', () => {
+ // VueDatePicker may give midnight local - e.g. 2025-03-02T08:00:00Z for PST
+ const selectedDate = new Date(2025, 2, 2, 0, 0, 0, 0);
+
+ const result = normalizeDateForStorage(selectedDate, false);
+
+ expect(result.toISOString()).toBe('2025-03-02T00:00:00.000Z');
+ });
+ });
+
+ test.describe('when endOfDay is true (for range end dates)', () => {
+ test('stores value as T23:59:59.999Z on the selected date', () => {
+ const selectedDate = new Date(2025, 2, 15, 9, 0, 0, 0); // March 15, 2025 9:00 AM local
+
+ const result = normalizeDateForStorage(selectedDate, false, true);
+
+ expect(result.toISOString()).toBe('2025-03-15T23:59:59.999Z');
+ });
+ });
+
+ test.describe('when time picker is enabled', () => {
+ test('preserves the full date and time without normalization', () => {
+ const selectedDate = new Date(2025, 2, 2, 14, 30, 45, 123);
+
+ const result = normalizeDateForStorage(selectedDate, true);
+
+ expect(result.getTime()).toBe(selectedDate.getTime());
+ expect(result.toISOString()).not.toMatch(/T00:00:00\.000Z$/);
+ });
+ });
+});
diff --git a/tests/unit/dateRangeField.spec.ts b/tests/unit/dateRangeField.spec.ts
new file mode 100644
index 0000000..c38e3d6
--- /dev/null
+++ b/tests/unit/dateRangeField.spec.ts
@@ -0,0 +1,84 @@
+import { test, expect } from '@playwright/test';
+import {
+ normalizeDateForStorage,
+ parseIsoDateForDisplay,
+} from '../../src/frontend/shared/helpers';
+
+/**
+ * Mirrors date-range-field.vue onUpdate logic.
+ * Field is date-only (enableTimePicker: false).
+ * Start at T00:00:00.000Z, end at T23:59:59.999Z so the range includes the full end day.
+ */
+function formatDateRangeAsComponentDoes(start: Date, end: Date): string {
+ const normalizedStart = normalizeDateForStorage(start, false);
+ const normalizedEnd = normalizeDateForStorage(end, false, true);
+ return `${normalizedStart.toISOString()}|${normalizedEnd.toISOString()}`;
+}
+
+test.describe('Date Range Field storage normalization (date-only, no time picker)', () => {
+ test.describe('when user selects a date range', () => {
+ test('stores start at T00:00:00.000Z and end at T23:59:59.999Z on selected dates', () => {
+ const startDate = new Date('March 2, 2025 14:30:00'); // March 2, 2025 2:30 PM local
+ const endDate = new Date('March 15, 2025 9:00:00'); // March 15, 2025 9:00 AM local
+
+ const result = formatDateRangeAsComponentDoes(startDate, endDate);
+
+ expect(result).toBe('2025-03-02T00:00:00.000Z|2025-03-15T23:59:59.999Z');
+ });
+
+ test('produces format compatible with dateRangeRule validator', () => {
+ const startDate = new Date(2025, 0, 1, 0, 0, 0, 0);
+ const endDate = new Date(2025, 11, 31, 23, 59, 59, 999);
+
+ const result = formatDateRangeAsComponentDoes(startDate, endDate);
+
+ expect(result).toBe('2025-01-01T00:00:00.000Z|2025-12-31T23:59:59.999Z');
+ });
+
+ test('normalizes adjacent days regardless of picker time', () => {
+ const startDate = new Date(2025, 5, 14, 8, 0, 0, 0);
+ const endDate = new Date(2025, 5, 15, 20, 0, 0, 0);
+
+ const result = formatDateRangeAsComponentDoes(startDate, endDate);
+
+ expect(result).toBe('2025-06-14T00:00:00.000Z|2025-06-15T23:59:59.999Z');
+ });
+ });
+
+ test.describe('parseIsoDateForDisplay (picker display, date-only)', () => {
+ test('extracts calendar date from end-of-day ISO so picker shows correct day in all timezones', () => {
+ // T23:59:59.999Z would show as April 16 in UTC+ without this fix
+ const parsed = parseIsoDateForDisplay('2025-04-15T23:59:59.999Z');
+ expect(parsed).not.toBeNull();
+ expect(parsed!.getFullYear()).toBe(2025);
+ expect(parsed!.getMonth()).toBe(3); // April
+ expect(parsed!.getDate()).toBe(15);
+ });
+
+ test('extracts calendar date from start-of-day ISO', () => {
+ const parsed = parseIsoDateForDisplay('2025-04-15T00:00:00.000Z');
+ expect(parsed).not.toBeNull();
+ expect(parsed!.getFullYear()).toBe(2025);
+ expect(parsed!.getMonth()).toBe(3);
+ expect(parsed!.getDate()).toBe(15);
+ });
+ });
+
+ test.describe('campaign window visibility (date-only)', () => {
+ test('start at T00:00:00.000Z allows campaign visibility from midnight on that date', () => {
+ const startDate = new Date(2025, 2, 10, 12, 0, 0, 0); // User selects March 10
+
+ const normalized = normalizeDateForStorage(startDate, false);
+
+ expect(normalized.toISOString()).toBe('2025-03-10T00:00:00.000Z');
+ });
+
+ test('end at T23:59:59.999Z includes full campaign window through last day', () => {
+ const endDate = new Date(2025, 2, 20, 9, 0, 0, 0); // User selects March 20
+
+ const normalized = normalizeDateForStorage(endDate, false, true);
+
+ expect(normalized.toISOString()).toBe('2025-03-20T23:59:59.999Z');
+ });
+ });
+});