diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateElementSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateElementSummary.tsx index 6adc63efb7..367ca84318 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateElementSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateElementSummary.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { DEFAULT_DATE_STORAGE_FORMAT } from "@formbricks/types/surveys/date-formats"; import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { timeSince } from "@/lib/time"; @@ -32,7 +33,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca }; const renderResponseValue = (value: string) => { - const format = elementSummary.element?.format ?? "y-M-d"; + const format = elementSummary.element?.format ?? DEFAULT_DATE_STORAGE_FORMAT; return formatStoredDateForDisplay(value, format, `${t("common.invalid_date")}(${value})`); }; diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx index f014a6da4f..a99ad4a5be 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx @@ -2,6 +2,7 @@ import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react"; import React from "react"; import { logger } from "@formbricks/logger"; import { TResponseDataValue } from "@formbricks/types/responses"; +import { DEFAULT_DATE_STORAGE_FORMAT } from "@formbricks/types/surveys/date-formats"; import { TSurveyDateElement, TSurveyElement, @@ -68,7 +69,7 @@ export const RenderResponse: React.FC = ({ break; case TSurveyElementTypeEnum.Date: if (typeof responseData === "string") { - const format = (element as TSurveyDateElement).format ?? "y-M-d"; + const format = element.format ?? DEFAULT_DATE_STORAGE_FORMAT; const formatted = formatStoredDateForDisplay(responseData, format, responseData); if (formatted === responseData) { logger.warn( diff --git a/apps/web/modules/survey/editor/components/date-element-form.tsx b/apps/web/modules/survey/editor/components/date-element-form.tsx index fc25faf392..e4ee71a30b 100644 --- a/apps/web/modules/survey/editor/components/date-element-form.tsx +++ b/apps/web/modules/survey/editor/components/date-element-form.tsx @@ -4,6 +4,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { PlusIcon } from "lucide-react"; import { type JSX } from "react"; import { useTranslation } from "react-i18next"; +import { DATE_STORAGE_FORMAT_IDS, DATE_STORAGE_FORMAT_LABELS } from "@formbricks/types/surveys/date-formats"; import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements"; import { TSurvey } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; @@ -27,20 +28,10 @@ interface IDateElementFormProps { isExternalUrlsAllowed?: boolean; } -const dateOptions = [ - { - value: "M-d-y", - label: "MM-DD-YYYY", - }, - { - value: "d-M-y", - label: "DD-MM-YYYY", - }, - { - value: "y-M-d", - label: "YYYY-MM-DD", - }, -]; +const dateOptions = DATE_STORAGE_FORMAT_IDS.map((value) => ({ + value, + label: DATE_STORAGE_FORMAT_LABELS[value], +})); export const DateElementForm = ({ element, @@ -121,9 +112,7 @@ export const DateElementForm = ({ - updateElement(elementIdx, { format: value as "M-d-y" | "d-M-y" | "y-M-d" }) - } + handleOptionChange={(value: string) => updateElement(elementIdx, { format: value })} /> diff --git a/packages/survey-ui/package.json b/packages/survey-ui/package.json index 4bcf42fa10..0f4442bc5c 100644 --- a/packages/survey-ui/package.json +++ b/packages/survey-ui/package.json @@ -67,6 +67,7 @@ "react-dom": "^19.0.0" }, "dependencies": { + "@formbricks/types": "workspace:*", "@formkit/auto-animate": "0.9.0", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-dropdown-menu": "2.1.16", diff --git a/packages/survey-ui/src/components/elements/date.tsx b/packages/survey-ui/src/components/elements/date.tsx index f903e9c3b8..e0b6bf3c89 100644 --- a/packages/survey-ui/src/components/elements/date.tsx +++ b/packages/survey-ui/src/components/elements/date.tsx @@ -1,37 +1,32 @@ import * as React from "react"; +import { + DATE_FORMAT_OUTPUT_ORDER, + DATE_FORMAT_PARSE_ORDER, + DEFAULT_DATE_STORAGE_FORMAT, + type TSurveyDateStorageFormat, +} from "@formbricks/types/surveys/date-formats"; import { Calendar } from "@/components/general/calendar"; import { ElementError } from "@/components/general/element-error"; import { ElementHeader } from "@/components/general/element-header"; import { getDateFnsLocale } from "@/lib/locale"; -/** Storage format for date response values: M=month, d=day, y=year */ -export type DateStorageFormat = "M-d-y" | "d-M-y" | "y-M-d"; +export type DateStorageFormat = TSurveyDateStorageFormat; + +const ISO_FIRST_CHARS = /^\d{4}/; function parseValueToDate(value: string, format: DateStorageFormat): Date | undefined { const trimmed = value?.trim(); if (!trimmed) return undefined; const parts = trimmed.split("-"); if (parts.length !== 3) return undefined; - const [a, b, c] = parts.map((p) => parseInt(p, 10)); - if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c)) return undefined; - const useIso = /^\d{4}/.test(trimmed); - const effective = useIso ? "y-M-d" : format; - let year: number; - let month: number; - let day: number; - if (effective === "y-M-d") { - year = a; - month = b; - day = c; - } else if (effective === "d-M-y") { - day = a; - month = b; - year = c; - } else { - month = a; - day = b; - year = c; - } + const nums = parts.map((p) => Number.parseInt(p, 10)); + if (nums.some(Number.isNaN)) return undefined; + const useIso = ISO_FIRST_CHARS.test(trimmed); + const effective = useIso ? DEFAULT_DATE_STORAGE_FORMAT : format; + const order = DATE_FORMAT_PARSE_ORDER[effective]; + const year = nums[order.yearIdx]; + const month = nums[order.monthIdx]; + const day = nums[order.dayIdx]; if (month < 1 || month > 12 || day < 1 || day > 31) return undefined; const date = new Date(year, month - 1, day); if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) @@ -40,16 +35,9 @@ function parseValueToDate(value: string, format: DateStorageFormat): Date | unde } function formatDateForStorage(year: string, month: string, day: string, format: DateStorageFormat): string { - switch (format) { - case "y-M-d": - return `${year}-${month}-${day}`; - case "M-d-y": - return `${month}-${day}-${year}`; - case "d-M-y": - return `${day}-${month}-${year}`; - default: - return `${year}-${month}-${day}`; - } + const comps = [year, month, day]; + const [i, j, k] = DATE_FORMAT_OUTPUT_ORDER[format]; + return `${comps[i]}-${comps[j]}-${comps[k]}`; } interface DateElementProps { @@ -96,7 +84,7 @@ function DateElement({ inputId, value, onChange, - outputFormat = "y-M-d", + outputFormat = DEFAULT_DATE_STORAGE_FORMAT, required = false, requiredLabel, minDate, @@ -121,8 +109,8 @@ function DateElement({ const newDate = parseValueToDate(value, outputFormat); setDate((prevDate) => { if (!newDate) return undefined; - if (!prevDate || newDate.getTime() !== prevDate.getTime()) return newDate; - return prevDate; + if (prevDate?.getTime() !== newDate.getTime()) return newDate; + return prevDate ?? undefined; }); }, [value, outputFormat]); diff --git a/packages/surveys/src/components/elements/date-element.tsx b/packages/surveys/src/components/elements/date-element.tsx index 4d8dd2f608..be1de1d2a6 100644 --- a/packages/surveys/src/components/elements/date-element.tsx +++ b/packages/surveys/src/components/elements/date-element.tsx @@ -2,6 +2,7 @@ import { useState } from "preact/hooks"; import { useTranslation } from "react-i18next"; import { DateElement as SurveyUIDateElement } from "@formbricks/survey-ui"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; +import { DEFAULT_DATE_STORAGE_FORMAT } from "@formbricks/types/surveys/date-formats"; import type { TSurveyDateElement } from "@formbricks/types/surveys/elements"; import { TSurveyLanguage } from "@formbricks/types/surveys/types"; import { getLocalizedValue } from "@/lib/i18n"; @@ -68,7 +69,7 @@ export function DateElement({ description={element.subheader ? getLocalizedValue(element.subheader, languageCode) : undefined} value={value} onChange={handleChange} - outputFormat={element.format ?? "y-M-d"} + outputFormat={element.format ?? DEFAULT_DATE_STORAGE_FORMAT} minDate={getMinDate()} maxDate={getMaxDate()} required={isRequired} diff --git a/packages/surveys/src/lib/date-format.ts b/packages/surveys/src/lib/date-format.ts index 217ff58074..4a5d7e5561 100644 --- a/packages/surveys/src/lib/date-format.ts +++ b/packages/surveys/src/lib/date-format.ts @@ -1,14 +1,17 @@ -/** - * Date storage format for survey date elements. - * Values are stored in response data in this order: y = year, M = month, d = day. - */ -export type TSurveyDateStorageFormat = "M-d-y" | "d-M-y" | "y-M-d"; +import { + DATE_FORMAT_PARSE_ORDER, + DATE_STORAGE_FORMATS_LIST, + DEFAULT_DATE_STORAGE_FORMAT, + type TSurveyDateStorageFormat, +} from "@formbricks/types/surveys/date-formats"; + +export type { TSurveyDateStorageFormat }; const ISO_FIRST_CHARS = /^\d{4}/; /** * Parse a date string stored in response data using the element's storage format. - * Pure function, synchronous, no I/O. + * Uses the format registry from @formbricks/types for data-driven parsing. * * Backward compatibility: if the value starts with 4 digits (YYYY), it is treated * as ISO (y-M-d) regardless of format, so legacy YYYY-MM-DD values parse correctly. @@ -17,7 +20,10 @@ const ISO_FIRST_CHARS = /^\d{4}/; * @param format - The format used when the value was stored; defaults to "y-M-d" (ISO) * @returns Parsed Date in local time, or null if invalid */ -export function parseDateByFormat(value: string, format: TSurveyDateStorageFormat = "y-M-d"): Date | null { +export function parseDateByFormat( + value: string, + format: TSurveyDateStorageFormat = DEFAULT_DATE_STORAGE_FORMAT +): Date | null { const trimmed = value?.trim(); if (!trimmed || typeof trimmed !== "string") { return null; @@ -28,44 +34,16 @@ export function parseDateByFormat(value: string, format: TSurveyDateStorageForma return null; } - // Backward compat: value starts with 4 digits (year) => treat as ISO const useIso = ISO_FIRST_CHARS.test(trimmed); - const effectiveFormat = useIso ? "y-M-d" : format; + const effectiveFormat = useIso ? DEFAULT_DATE_STORAGE_FORMAT : format; - let year: number; - let month: number; - let day: number; + const order = DATE_FORMAT_PARSE_ORDER[effectiveFormat]; + const nums = parts.map((p) => Number.parseInt(p, 10)); + if (nums.some(Number.isNaN)) return null; + const year = nums[order.yearIdx]; + const month = nums[order.monthIdx]; + const day = nums[order.dayIdx]; - switch (effectiveFormat) { - case "y-M-d": { - const [y, m, d] = parts.map((p) => parseInt(p, 10)); - if (Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) return null; - year = y; - month = m; - day = d; - break; - } - case "d-M-y": { - const [d, m, y] = parts.map((p) => parseInt(p, 10)); - if (Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) return null; - year = y; - month = m; - day = d; - break; - } - case "M-d-y": { - const [m, d, y] = parts.map((p) => parseInt(p, 10)); - if (Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) return null; - year = y; - month = m; - day = d; - break; - } - default: - return null; - } - - // Month 1-12, day 1-31; Date constructor uses 0-indexed month if (month < 1 || month > 12 || day < 1 || day > 31) { return null; } @@ -78,8 +56,6 @@ export function parseDateByFormat(value: string, format: TSurveyDateStorageForma return date; } -const STORAGE_FORMATS: TSurveyDateStorageFormat[] = ["y-M-d", "d-M-y", "M-d-y"]; - /** * Try to parse a date string using each known storage format in order. * Use when the storage format is unknown (e.g. recall placeholders). @@ -88,7 +64,7 @@ const STORAGE_FORMATS: TSurveyDateStorageFormat[] = ["y-M-d", "d-M-y", "M-d-y"]; * @returns Parsed Date or null if no format matched */ export function parseDateWithFormats(value: string): Date | null { - for (const format of STORAGE_FORMATS) { + for (const format of DATE_STORAGE_FORMATS_LIST) { const parsed = parseDateByFormat(value, format); if (parsed !== null) return parsed; } diff --git a/packages/surveys/src/lib/logic.ts b/packages/surveys/src/lib/logic.ts index a4a5417c06..a83919f81a 100644 --- a/packages/surveys/src/lib/logic.ts +++ b/packages/surveys/src/lib/logic.ts @@ -2,6 +2,7 @@ import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses"; import { type TActionCalculate, type TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks"; import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants"; +import { DEFAULT_DATE_STORAGE_FORMAT } from "@formbricks/types/surveys/date-formats"; import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements"; import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic"; import { type TSurveyVariable } from "@formbricks/types/surveys/types"; @@ -9,11 +10,18 @@ import { parseDateByFormat } from "@/lib/date-format"; import { getLocalizedValue } from "@/lib/i18n"; import { getElementsFromSurveyBlocks } from "./utils"; +/** Coerce to string for date comparison; avoids Object's default stringification. */ +function toDateOperandString(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + return ""; +} + function parseDateOperand(value: string, field: TSurveyElement | ""): Date | null { const format = field && typeof field === "object" && field.type === TSurveyElementTypeEnum.Date && "format" in field - ? ((field as TSurveyDateElement).format ?? "y-M-d") - : "y-M-d"; + ? (field.format ?? DEFAULT_DATE_STORAGE_FORMAT) + : DEFAULT_DATE_STORAGE_FORMAT; return parseDateByFormat(value, format); } @@ -26,7 +34,7 @@ function compareDateOperands( compare: (left: Date, right: Date) => boolean ): boolean { const leftDate = parseDateOperand(leftValue, leftField); - const rightDate = parseDateOperand(String(rightValue), rightField); + const rightDate = parseDateOperand(rightValue, rightField); if (leftDate === null) { console.warn(`[logic] ${operator}: could not parse left date`, { elementId: leftField.id, @@ -468,19 +476,19 @@ const evaluateSingleCondition = ( return leftValue !== "clicked"; case "isAfter": return compareDateOperands( - String(leftValue), - String(rightValue), - leftField as TSurveyElement, - rightField as TSurveyElement, + toDateOperandString(leftValue), + toDateOperandString(rightValue), + leftField, + rightField, "isAfter", (l, r) => l.getTime() > r.getTime() ); case "isBefore": return compareDateOperands( - String(leftValue), - String(rightValue), - leftField as TSurveyElement, - rightField as TSurveyElement, + toDateOperandString(leftValue), + toDateOperandString(rightValue), + leftField, + rightField, "isBefore", (l, r) => l.getTime() < r.getTime() ); diff --git a/packages/surveys/src/lib/validation/validators.ts b/packages/surveys/src/lib/validation/validators.ts index e51af29d1a..d97847cd5b 100644 --- a/packages/surveys/src/lib/validation/validators.ts +++ b/packages/surveys/src/lib/validation/validators.ts @@ -1,5 +1,9 @@ import type { TFunction } from "i18next"; import type { TResponseDataValue } from "@formbricks/types/responses"; +import { + DEFAULT_DATE_STORAGE_FORMAT, + type TSurveyDateStorageFormat, +} from "@formbricks/types/surveys/date-formats"; import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements"; import type { TValidationRuleParams, @@ -31,22 +35,22 @@ import { parseDateByFormat } from "@/lib/date-format"; import { countSelections } from "./validators/selection-utils"; import { validateEmail, validatePhone, validateUrl } from "./validators/validation-utils"; -function getDateElementFormat(element: TSurveyElement): "M-d-y" | "d-M-y" | "y-M-d" { +function getDateElementFormat(element: TSurveyElement): TSurveyDateStorageFormat { if (element.type === "date" && "format" in element) { - return (element as TSurveyDateElement).format ?? "y-M-d"; + return element.format ?? DEFAULT_DATE_STORAGE_FORMAT; } - return "y-M-d"; + return DEFAULT_DATE_STORAGE_FORMAT; } function parseForDateComparison( value: string, - format: "M-d-y" | "d-M-y" | "y-M-d", + format: TSurveyDateStorageFormat, element: TSurveyElement, ruleName: string, paramDates: [string] | [string, string] ): { valueDate: Date; paramDates: Date[] } | null { const valueDate = parseDateByFormat(value, format); - const parsedParams = paramDates.map((d) => parseDateByFormat(d, "y-M-d")); + const parsedParams = paramDates.map((d) => parseDateByFormat(d, DEFAULT_DATE_STORAGE_FORMAT)); if (valueDate === null) { console.warn(`[date validation] ${ruleName}: could not parse response date`, { elementId: element.id, @@ -55,7 +59,7 @@ function parseForDateComparison( }); return null; } - if (parsedParams.some((d) => d === null)) return null; + if (parsedParams.includes(null)) return null; return { valueDate, paramDates: parsedParams as Date[] }; } diff --git a/packages/types/surveys/date-formats.ts b/packages/types/surveys/date-formats.ts new file mode 100644 index 0000000000..d47a5f6ec3 --- /dev/null +++ b/packages/types/surveys/date-formats.ts @@ -0,0 +1,49 @@ +/** + * Single source of truth for survey date storage formats. + * Values are stored in response data as hyphen-separated strings: year, month, day + * in the order defined by each format (e.g. y-M-d = YYYY-MM-DD). + * + * To add a new format: add the id to the tuple, add parse order and output order + * entries, then implement parsing/formatting in packages that consume this (surveys, survey-ui). + */ + +/** Supported date storage format ids. Extend this tuple to add new formats. */ +export const DATE_STORAGE_FORMAT_IDS = ["M-d-y", "d-M-y", "y-M-d"] as const; + +export type TSurveyDateStorageFormat = (typeof DATE_STORAGE_FORMAT_IDS)[number]; + +/** Default format (ISO-style). Used when element has no format set. */ +export const DEFAULT_DATE_STORAGE_FORMAT: TSurveyDateStorageFormat = "y-M-d"; + +/** + * For each format, indices into the split("-") array for [year, month, day]. + * parts[yearIdx]=year, parts[monthIdx]=month, parts[dayIdx]=day. + */ +export const DATE_FORMAT_PARSE_ORDER: Record< + TSurveyDateStorageFormat, + { yearIdx: number; monthIdx: number; dayIdx: number } +> = { + "y-M-d": { yearIdx: 0, monthIdx: 1, dayIdx: 2 }, + "d-M-y": { yearIdx: 2, monthIdx: 1, dayIdx: 0 }, + "M-d-y": { yearIdx: 2, monthIdx: 0, dayIdx: 1 }, +}; + +/** + * For each format, indices into [year, month, day] for output order. + * Output = [year, month, day][out[0]] + "-" + [year, month, day][out[1]] + "-" + [year, month, day][out[2]] + */ +export const DATE_FORMAT_OUTPUT_ORDER: Record = { + "y-M-d": [0, 1, 2], + "d-M-y": [2, 1, 0], + "M-d-y": [1, 2, 0], +}; + +/** All format ids as an array (for iteration, e.g. try-parse or dropdowns). */ +export const DATE_STORAGE_FORMATS_LIST: TSurveyDateStorageFormat[] = [...DATE_STORAGE_FORMAT_IDS]; + +/** Default display labels for UI (e.g. editor dropdown). Apps can override with i18n. */ +export const DATE_STORAGE_FORMAT_LABELS: Record = { + "M-d-y": "MM-DD-YYYY", + "d-M-y": "DD-MM-YYYY", + "y-M-d": "YYYY-MM-DD", +}; diff --git a/packages/types/surveys/elements.ts b/packages/types/surveys/elements.ts index 7b9b027b9a..ffae2012e4 100644 --- a/packages/types/surveys/elements.ts +++ b/packages/types/surveys/elements.ts @@ -3,6 +3,7 @@ import { ZStorageUrl, ZUrl } from "../common"; import { ZI18nString } from "../i18n"; import { ZAllowedFileExtension } from "../storage"; import { TSurveyElementTypeEnum } from "./constants"; +import { DATE_STORAGE_FORMAT_IDS } from "./date-formats"; import { FORBIDDEN_IDS } from "./validation"; import { ZValidationRules } from "./validation-rules"; @@ -257,7 +258,7 @@ export type TSurveyPictureSelectionElement = z.infer