From 59edb8dbd1cb6963585035afe6e7c6488b01512d Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Mon, 2 Mar 2026 08:02:07 +0300 Subject: [PATCH 1/8] feat: set generic time if timepicker is disabled --- src/frontend/fields/date-field.vue | 5 ++- src/frontend/shared/helpers.ts | 21 +++++++++++ tests/unit/dateField.spec.ts | 56 ++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/unit/dateField.spec.ts diff --git a/src/frontend/fields/date-field.vue b/src/frontend/fields/date-field.vue index d78e25da..82812f14 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,9 @@ model.$subscribe(() => { const onUpdate = (date: Date) => { if (props.isReadOnly) return; - model.setField(fieldPath.value, date); + const hasTimePicker = field.value.hasTimePicker ?? false; + const valueToStore = normalizeDateForStorage(date, hasTimePicker); + model.setField(fieldPath.value, valueToStore); }; const errors = computed(() => shared.errorMessages(fieldPath.value)); diff --git a/src/frontend/shared/helpers.ts b/src/frontend/shared/helpers.ts index 4b5d29c7..b4d7fd46 100644 --- a/src/frontend/shared/helpers.ts +++ b/src/frontend/shared/helpers.ts @@ -185,3 +185,24 @@ 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. + * Ensures the stored value is T00:00:00.000Z on the selected date so that: + * - When user selects today: element is visible instantly (valid at T00:00:00.000Z) + * - When user selects any other day: element is shown from T00:00:00.000Z on that date + * + * @param date - The date selected by the user + * @param hasTimePicker - Whether the field has time picker enabled + * @returns The date to store (Date object that serializes to ISO string) + */ +export function normalizeDateForStorage(date: Date, hasTimePicker: boolean): Date { + if (hasTimePicker) { + return date; + } + // For date-only selection: store as T00:00:00.000Z on the selected calendar date + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDate(); + return new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); +} diff --git a/tests/unit/dateField.spec.ts b/tests/unit/dateField.spec.ts new file mode 100644 index 00000000..7d349fdb --- /dev/null +++ b/tests/unit/dateField.spec.ts @@ -0,0 +1,56 @@ +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 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$/); + }); + }); +}); From b52c769c5f07209290c7ff702a7719462caa718e Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Mon, 2 Mar 2026 08:14:39 +0300 Subject: [PATCH 2/8] feat: normalize date storage format in date range field and add unit tests --- src/frontend/fields/date-range-field.vue | 6 ++- tests/unit/dateRangeField.spec.ts | 61 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/unit/dateRangeField.spec.ts diff --git a/src/frontend/fields/date-range-field.vue b/src/frontend/fields/date-range-field.vue index e79b1224..1544cce0 100644 --- a/src/frontend/fields/date-range-field.vue +++ b/src/frontend/fields/date-range-field.vue @@ -44,6 +44,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'; type WidgetValue = [Date | null, Date | null]; @@ -100,7 +101,10 @@ const onUpdate = (date: WidgetValue) => { return; } - const fresh = `${date[0].toISOString()}|${date[1].toISOString()}`; + // Date range is always date-only (no time picker) - store as T00:00:00.000Z + const start = normalizeDateForStorage(date[0], false); + const end = normalizeDateForStorage(date[1], false); + const fresh = `${start.toISOString()}|${end.toISOString()}`; model.setField(fieldPath.value, fresh); }; diff --git a/tests/unit/dateRangeField.spec.ts b/tests/unit/dateRangeField.spec.ts new file mode 100644 index 00000000..ea8e15e6 --- /dev/null +++ b/tests/unit/dateRangeField.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; +import { normalizeDateForStorage } from '../../src/frontend/shared/helpers'; + +/** + * Helpers that mirror date-range-field.vue storage format. + * Date range is always date-only - both dates stored as T00:00:00.000Z. + */ +function formatDateRangeForStorage(start: Date, end: Date): string { + const normalizedStart = normalizeDateForStorage(start, false); + const normalizedEnd = normalizeDateForStorage(end, false); + return `${normalizedStart.toISOString()}|${normalizedEnd.toISOString()}`; +} + +test.describe('Date Range Field storage normalization', () => { + test.describe('when user selects a date range (start and end dates)', () => { + test('stores both dates as T00:00:00.000Z on the selected dates', () => { + const startDate = new Date(2025, 2, 2, 14, 30, 0, 0); // March 2, 2025 2:30 PM local + const endDate = new Date(2025, 2, 15, 9, 0, 0, 0); // March 15, 2025 9:00 AM local + + const result = formatDateRangeForStorage(startDate, endDate); + + expect(result).toBe('2025-03-02T00:00:00.000Z|2025-03-15T00:00:00.000Z'); + }); + + 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 = formatDateRangeForStorage(startDate, endDate); + + expect(result).toBe('2025-01-01T00:00:00.000Z|2025-12-31T00:00:00.000Z'); + }); + + test('normalizes adjacent days to midnight UTC each', () => { + const startDate = new Date(2025, 5, 14, 8, 0, 0, 0); + const endDate = new Date(2025, 5, 15, 20, 0, 0, 0); + + const result = formatDateRangeForStorage(startDate, endDate); + + expect(result).toBe('2025-06-14T00:00:00.000Z|2025-06-15T00:00:00.000Z'); + }); + }); + + test.describe('campaign window visibility', () => { + test('start date 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 date at T00:00:00.000Z on last day includes full campaign window', () => { + const endDate = new Date(2025, 2, 20, 23, 59, 59, 999); // User selects March 20 + + const normalized = normalizeDateForStorage(endDate, false); + + expect(normalized.toISOString()).toBe('2025-03-20T00:00:00.000Z'); + }); + }); +}); From 83c1a5f2cb86237ad2f5bcb2336b724c71762594 Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Mon, 2 Mar 2026 09:24:32 +0300 Subject: [PATCH 3/8] feat: update date normalization logic to include end-of-day handling for date ranges --- src/frontend/fields/date-range-field.vue | 7 ++--- src/frontend/shared/helpers.ts | 11 ++++++-- tests/unit/dateField.spec.ts | 10 +++++++ tests/unit/dateRangeField.spec.ts | 33 ++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/frontend/fields/date-range-field.vue b/src/frontend/fields/date-range-field.vue index 1544cce0..fd4f1ce4 100644 --- a/src/frontend/fields/date-range-field.vue +++ b/src/frontend/fields/date-range-field.vue @@ -101,10 +101,11 @@ const onUpdate = (date: WidgetValue) => { return; } - // Date range is always date-only (no time picker) - store as T00:00:00.000Z + // Date range is always date-only (no time picker) - start at T00:00:00.000Z const start = normalizeDateForStorage(date[0], false); - const end = normalizeDateForStorage(date[1], false); - const fresh = `${start.toISOString()}|${end.toISOString()}`; + // 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/shared/helpers.ts b/src/frontend/shared/helpers.ts index b4d7fd46..301bdceb 100644 --- a/src/frontend/shared/helpers.ts +++ b/src/frontend/shared/helpers.ts @@ -194,15 +194,22 @@ export const getCampaignStatus = ( * * @param date - The date selected by the user * @param hasTimePicker - Whether the field has time picker enabled + * @param endOfDay - When true (date-only only), store as T23:59:59.999Z for range end dates * @returns The date to store (Date object that serializes to ISO string) */ -export function normalizeDateForStorage(date: Date, hasTimePicker: boolean): Date { +export function normalizeDateForStorage( + date: Date, + hasTimePicker: boolean, + endOfDay = false, +): Date { if (hasTimePicker) { return date; } - // For date-only selection: store as T00:00:00.000Z on the selected calendar 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)); } diff --git a/tests/unit/dateField.spec.ts b/tests/unit/dateField.spec.ts index 7d349fdb..f0e0b949 100644 --- a/tests/unit/dateField.spec.ts +++ b/tests/unit/dateField.spec.ts @@ -43,6 +43,16 @@ test.describe('Date Field storage normalization', () => { }); }); + 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); diff --git a/tests/unit/dateRangeField.spec.ts b/tests/unit/dateRangeField.spec.ts index ea8e15e6..c4edac72 100644 --- a/tests/unit/dateRangeField.spec.ts +++ b/tests/unit/dateRangeField.spec.ts @@ -11,6 +11,16 @@ function formatDateRangeForStorage(start: Date, end: Date): string { return `${normalizedStart.toISOString()}|${normalizedEnd.toISOString()}`; } +/** + * Mirrors date-range-field.vue onUpdate logic. + * 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', () => { test.describe('when user selects a date range (start and end dates)', () => { test('stores both dates as T00:00:00.000Z on the selected dates', () => { @@ -41,6 +51,29 @@ test.describe('Date Range Field storage normalization', () => { }); }); + test.describe('date-range-field onUpdate logic', () => { + test('stores start at T00:00:00.000Z and end at T23:59:59.999Z for full-day coverage', () => { + const startDate = new Date(2025, 2, 2, 14, 30, 0, 0); // March 2, 2025 2:30 PM local + const endDate = new Date(2025, 2, 15, 9, 0, 0, 0); // 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('uses pipe-separated ISO format for storage', () => { + const startDate = new Date(2025, 0, 1, 0, 0, 0, 0); + const endDate = new Date(2025, 0, 31, 12, 0, 0, 0); + + const result = formatDateRangeAsComponentDoes(startDate, endDate); + + const [startPart, endPart] = result.split('|'); + expect(startPart).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(endPart).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(result).toBe('2025-01-01T00:00:00.000Z|2025-01-31T23:59:59.999Z'); + }); + }); + test.describe('campaign window visibility', () => { test('start date 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 From 22572e833037f26aac71d44cca56bedfef4f2108 Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Mon, 2 Mar 2026 09:45:21 +0300 Subject: [PATCH 4/8] feat: add support for displaying end-of-day dates in date range picker and implement parsing logic --- .../fields/date-range-field.story.vue | 10 +++++++ src/frontend/fields/date-range-field.vue | 13 ++++------ src/frontend/shared/helpers.ts | 15 +++++++++++ tests/unit/dateRangeField.spec.ts | 26 +++++++++++++++++-- 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/frontend/fields/date-range-field.story.vue b/src/frontend/fields/date-range-field.story.vue index d253e6ff..b3db71ef 100644 --- a/src/frontend/fields/date-range-field.story.vue +++ b/src/frontend/fields/date-range-field.story.vue @@ -10,6 +10,11 @@ + + + + +