Skip to content
4 changes: 3 additions & 1 deletion src/frontend/fields/date-field.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down
10 changes: 10 additions & 0 deletions src/frontend/fields/date-range-field.story.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
<ModelControl :model="objectModel" />
</Variant>

<Variant title="With defined end of day" :setup-app="loadData">
<DateRangeField :field="modelSpec" />
<ModelControl :model="objectModelWithDefinedEndOfDay" />
</Variant>

<Variant title="With error" :setup-app="loadData">
<DateRangeField :field="modelSpec" />
<template #controls>
Expand Down Expand Up @@ -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',
Expand Down
18 changes: 10 additions & 8 deletions src/frontend/fields/date-range-field.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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];
};
Expand All @@ -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);
};

Expand Down
6 changes: 4 additions & 2 deletions src/frontend/index.story.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
47 changes: 47 additions & 0 deletions src/frontend/shared/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
66 changes: 66 additions & 0 deletions tests/unit/dateField.spec.ts
Original file line number Diff line number Diff line change
@@ -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$/);
});
});
});
84 changes: 84 additions & 0 deletions tests/unit/dateRangeField.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});