diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx index 2ffb04c8cb4a..ba28fce31cc9 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx @@ -43,31 +43,12 @@ interface PersonalLinksFormData { expiryDate: Date | null; } -// Custom DatePicker component with date restrictions -const RestrictedDatePicker = ({ - date, - updateSurveyDate, -}: { - date: Date | null; - updateSurveyDate: (date: Date | null) => void; -}) => { - // Get tomorrow's date +// A personal link has to expire in the future, so the calendar starts at tomorrow. +const getTomorrow = (): Date => { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(0, 0, 0, 0); - - const handleDateUpdate = (date: Date) => { - updateSurveyDate(date); - }; - - return ( - updateSurveyDate(null)} - /> - ); + return tomorrow; }; export const PersonalLinksTab = ({ @@ -77,7 +58,7 @@ export const PersonalLinksTab = ({ isFormbricksCloud, enterpriseLicenseRequestFormUrl, }: PersonalLinksTabProps) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { workspace } = useWorkspace(); const form = useForm({ @@ -233,7 +214,13 @@ export const PersonalLinksTab = ({ {t("workspace.surveys.share.personal_links.expiry_date_optional")} - + field.onChange(null)} + /> {t("workspace.surveys.share.personal_links.expiry_date_description")} diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx index 2aa7363f0e74..734988e17380 100755 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx @@ -1,7 +1,6 @@ "use client"; import * as Sentry from "@sentry/nextjs"; -import { format } from "date-fns"; import { TFunction } from "i18next"; import { Loader2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -20,8 +19,9 @@ import { resolveDateRangeLabelPreset, resolveDateRangePresetBounds, } from "@/lib/date-ranges"; +import { formatDateForDisplay } from "@/lib/utils/datetime"; import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; -import { Calendar } from "@/modules/ui/components/calendar"; +import { DateRangeCalendar } from "@/modules/ui/components/date-picker"; import { DropdownMenu, DropdownMenuContent, @@ -30,11 +30,6 @@ import { } from "@/modules/ui/components/dropdown-menu"; import { PopoverTriggerButton, ResponseFilter } from "./ResponseFilter"; -enum DateSelected { - FROM = "common.from", - TO = "common.to", -} - enum FilterDownload { ALL = "common.all", FILTER = "common.filter", @@ -70,10 +65,23 @@ const DATE_RANGE_PRESETS: readonly { preset: TDateRangePreset; getLabel: (t: TFu const DATE_RANGE_PRESET_NAMES = DATE_RANGE_PRESETS.map(({ preset }) => preset); +const DAY_MONTH_OPTIONS: Intl.DateTimeFormatOptions = { day: "numeric", month: "short" }; + interface CustomFilterProps { survey: TSurvey; } +const getCustomRangeLabel = (dateRange: DateRange, locale: string | undefined, t: TFunction): string => { + const from = dateRange?.from + ? formatDateForDisplay(dateRange.from, locale, DAY_MONTH_OPTIONS) + : t("workspace.surveys.summary.select_first_date"); + const to = dateRange?.to + ? formatDateForDisplay(dateRange.to, locale, DAY_MONTH_OPTIONS) + : t("workspace.surveys.summary.select_last_date"); + + return `${from} - ${to}`; +}; + const getDateRangeLabel = (dateRange: DateRange, t: TFunction) => { const preset = resolveDateRangeLabelPreset(dateRange, DATE_RANGE_PRESET_NAMES); const matched = DATE_RANGE_PRESETS.find((p) => p.preset === preset); @@ -81,16 +89,17 @@ const getDateRangeLabel = (dateRange: DateRange, t: TFunction) => { }; export const CustomFilter = ({ survey }: Readonly) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + // `resolvedLanguage` is undefined until i18next finishes initialising, so fall back the way the + // rest of the app does rather than letting date formatting silently drop to en-US. + const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US"; const { selectedFilter, dateRange, setDateRange, resetState } = useResponseFilter(); const [filterRange, setFilterRange] = useState( dateRange.from && dateRange.to ? getDateRangeLabel(dateRange, t) : getFilterDropDownLabels(t).ALL_TIME ); - const [selectingDate, setSelectingDate] = useState(DateSelected.FROM); const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState(false); const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState(false); - const [hoveredRange, setHoveredRange] = useState(null); const [isDownloading, setIsDownloading] = useState(false); const firstMountRef = useRef(true); @@ -130,66 +139,8 @@ export const CustomFilter = ({ survey }: Readonly) => { return keys; }, []); - const handleDateHoveredChange = (date: Date) => { - if (selectingDate === DateSelected.FROM) { - const startOfRange = new Date(date); - startOfRange.setHours(0, 0, 0, 0); // Set to the start of the selected day - - // Check if the selected date is after the current 'to' date - if (startOfRange > dateRange?.to!) { - return; - } else { - setHoveredRange({ from: startOfRange, to: dateRange.to }); - } - } else { - const endOfRange = new Date(date); - endOfRange.setHours(23, 59, 59, 999); // Set to the end of the selected day - - // Check if the selected date is before the current 'from' date - if (endOfRange < dateRange?.from!) { - return; - } else { - setHoveredRange({ from: dateRange.from, to: endOfRange }); - } - } - }; - - const handleDateChange = (date: Date) => { - if (selectingDate === DateSelected.FROM) { - const startOfRange = new Date(date); - startOfRange.setHours(0, 0, 0, 0); // Set to the start of the selected day - - // Check if the selected date is after the current 'to' date - if (startOfRange > dateRange?.to!) { - const nextDay = new Date(startOfRange); - nextDay.setDate(nextDay.getDate() + 1); - nextDay.setHours(23, 59, 59, 999); - setDateRange({ from: startOfRange, to: nextDay }); - } else { - setDateRange((prevData) => ({ from: startOfRange, to: prevData.to })); - } - setSelectingDate(DateSelected.TO); - } else { - const endOfRange = new Date(date); - endOfRange.setHours(23, 59, 59, 999); // Set to the end of the selected day - - // Check if the selected date is before the current 'from' date - if (endOfRange < dateRange?.from!) { - const previousDay = new Date(endOfRange); - previousDay.setDate(previousDay.getDate() - 1); - previousDay.setHours(0, 0, 0, 0); // Set to the start of the selected day - setDateRange({ from: previousDay, to: endOfRange }); - } else { - setDateRange((prevData) => ({ from: prevData?.from, to: endOfRange })); - } - setIsDatePickerOpen(false); - setSelectingDate(DateSelected.FROM); - } - }; - const handleDatePickerClose = () => { setIsDatePickerOpen(false); - setSelectingDate(DateSelected.FROM); }; const handleDownloadResponses = async (filter: FilterDownload, fileType: "csv" | "xlsx") => { @@ -233,9 +184,7 @@ export const CustomFilter = ({ survey }: Readonly) => { {filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE - ? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${ - dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date" - }` + ? getCustomRangeLabel(dateRange, locale, t) : filterRange} @@ -261,7 +210,6 @@ export const CustomFilter = ({ survey }: Readonly) => { onClick={() => { setIsDatePickerOpen(true); setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE); - setSelectingDate(DateSelected.FROM); }}>

{getFilterDropDownLabels(t).CUSTOM_RANGE}

@@ -315,18 +263,11 @@ export const CustomFilter = ({ survey }: Readonly) => { {isDatePickerOpen && (
- handleDateChange(date)} - onDayMouseEnter={handleDateHoveredChange} - onDayMouseLeave={() => setHoveredRange(null)} - classNames={{ - day_today: "hover:bg-slate-200 bg-white", - }} + setIsDatePickerOpen(false)} />
)} diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index a5bbf93053fe..4726c5a91aab 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -182,6 +182,7 @@ checksums: common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603 common/choose_workspace: f9ed22d76c69cc75aa56cf3da3fa6320 common/clear_all: 854be0c051e4a3491a2cdd9dd8c1b4d5 + common/clear_date: c5f1b6ed772d5a9ce4e4d15e19c3ba36 common/clear_filters: 8f40ab5af527e4b190da94e7b6221379 common/clear_selection: af5d720527735d4253e289400d29ec9e common/click: 9c2744de6b5ac7333d9dae1d5cf4a76d @@ -386,6 +387,7 @@ checksums: common/phone: b9537ee90fc5b0116942e0af29d926cc common/photo_by: 3b96aa11f830dc89d6975ccbc93ad359 common/pick_a_date: 78a7959a5c1094c4f6e95523dd49a45a + common/pick_a_date_range: b7061cf5d72d1e8380b7e2ef41b09f06 common/picture: 14818ef364a0ecc8b738bdb23c46b3c3 common/placeholder: 88c2c168aff12ca70148fcb5f6b4c7b1 common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50 @@ -1648,7 +1650,6 @@ checksums: workspace/analysis/charts/emotion_value_sadness: 044000f011af6859ce501b08bdb4f96d workspace/analysis/charts/emotion_value_surprise: 0a7e8289b3a8513b2c00bdfef62d46e6 workspace/analysis/charts/enable_time_dimension: cfcf0af2d22bccd197319c07680c2cb8 - workspace/analysis/charts/end_date: acbea5a9fd7a6fadf5aa1b4f47188203 workspace/analysis/charts/enter_a_name_for_your_chart: b6e992a23d0628136121ebf26eec4a50 workspace/analysis/charts/enter_value: a4554ed67c02872e302b0042724f859d workspace/analysis/charts/equals: 264ec282f7f5b67da622cc37f2b57b8a @@ -1788,7 +1789,6 @@ checksums: workspace/analysis/charts/sentiment_value_very_negative: d8fc168ac8dee517c9d5960bfdd2c1d0 workspace/analysis/charts/sentiment_value_very_positive: 0b5952d9b44604c77b74bb1d3eeb29eb workspace/analysis/charts/showing_first_n_of: e9c1e76a46d0635f775a5b86bddbe1c3 - workspace/analysis/charts/start_date: 881de78c79b56f5ceb9b7103bf23cb2c workspace/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84 workspace/analysis/charts/time_dimension_title: 9353ce9a075a0cc8c3ba7dfa9ef19a8d workspace/analysis/charts/time_dimension_title_range_only: c5ddaa8d2cc006c57f027b7d3b87854d @@ -3751,6 +3751,8 @@ checksums: workspace/surveys/summary/quotas_completed_tooltip: ec5c4dc67eda27c06764354f695db613 workspace/surveys/summary/reset_survey: 8c88ddb81f5f787d183d2e7cb43e7c64 workspace/surveys/summary/reset_survey_warning: 6b44be171d7e2716f234387b100b173d + workspace/surveys/summary/select_first_date: 1b10e5ee3f7fb106a02ee11f514cce51 + workspace/surveys/summary/select_last_date: 3da08777980fdaf037ef395727a0ccfd workspace/surveys/summary/selected_responses_csv: 9cef3faccd54d4f24647791e6359db90 workspace/surveys/summary/selected_responses_excel: a0ade8b2658e887a4a3f2ad3bdb0c686 workspace/surveys/summary/setup_integrations: 602adcc10eeca23d162d4d2100ff5b58 diff --git a/apps/web/lib/utils/datetime.test.ts b/apps/web/lib/utils/datetime.test.ts index 1eca2109337b..33c4f785015f 100644 --- a/apps/web/lib/utils/datetime.test.ts +++ b/apps/web/lib/utils/datetime.test.ts @@ -4,8 +4,11 @@ import { formatDateForDisplay, formatDateTimeForDisplay, formatDateWithOrdinal, + formatLocalDay, + getDateFnsLocale, getFormattedDateTimeString, isValidDateString, + parseLocalDay, } from "./datetime"; describe("datetime utils", () => { @@ -93,3 +96,85 @@ describe("datetime utils", () => { expect(getFormattedDateTimeString(date, "Not/AZone")).toBe("2026-01-01 20:00:00 UTC"); }); }); + +describe("formatLocalDay / parseLocalDay", () => { + test("serialises the local calendar day, zero-padded", () => { + // Late in the day on purpose: a UTC-based serialiser would roll this to the 6th east of UTC. + expect(formatLocalDay(new Date(2026, 7, 5, 23, 30))).toBe("2026-08-05"); + expect(formatLocalDay(new Date(2026, 0, 1, 0, 0))).toBe("2026-01-01"); + expect(formatLocalDay(new Date(2026, 11, 31, 12, 0))).toBe("2026-12-31"); + }); + + test("round-trips through parseLocalDay to local midnight", () => { + const parsed = parseLocalDay("2026-08-05"); + + expect([parsed.getFullYear(), parsed.getMonth(), parsed.getDate()]).toEqual([2026, 7, 5]); + expect([parsed.getHours(), parsed.getMinutes()]).toEqual([0, 0]); + expect(formatLocalDay(parsed)).toBe("2026-08-05"); + }); + + test.each(["2026-01-01", "2026-03-08", "2026-08-05", "2026-11-01", "2026-12-31"])( + "is its own inverse for %s", + (day) => { + expect(formatLocalDay(parseLocalDay(day))).toBe(day); + } + ); +}); + +describe("getDateFnsLocale", () => { + // The calendar reads month, weekday and first-day-of-week off the returned locale, so the assertions + // are on the resolved locale's `code` rather than on object identity. + test.each([ + ["de-DE", "de"], + ["es-ES", "es"], + ["fr-FR", "fr"], + ["hu-HU", "hu"], + ["ja-JP", "ja"], + ["nl-NL", "nl"], + ["ro-RO", "ro"], + ["ru-RU", "ru"], + ["sv-SE", "sv"], + ["tr-TR", "tr"], + ["en-US", "en-US"], + ])("maps the app locale %s to date-fns %s", (appLocale, expected) => { + expect(getDateFnsLocale(appLocale).code).toBe(expected); + }); + + test.each([ + ["pt-BR", "pt-BR"], + ["pt-PT", "pt"], + ["pt", "pt-BR"], + ])("keeps Portuguese variants apart: %s", (appLocale, expected) => { + // pt-BR and pt-PT are different locales, so neither may be reached by cutting the tag to "pt". + expect(getDateFnsLocale(appLocale).code).toBe(expected); + }); + + test.each([ + ["zh-Hans-CN", "zh-CN"], + ["zh-cn", "zh-CN"], + ["zh-Hant-TW", "zh-TW"], + ["zh-tw", "zh-TW"], + ["zh-hk", "zh-TW"], + ["zh", "zh-CN"], + ])("resolves Chinese script tags: %s", (appLocale, expected) => { + expect(getDateFnsLocale(appLocale).code).toBe(expected); + }); + + test("is case-insensitive about the tag", () => { + expect(getDateFnsLocale("DE-de").code).toBe("de"); + expect(getDateFnsLocale("PT-br").code).toBe("pt-BR"); + }); + + test("falls back to en-US for an unset, empty or unknown locale", () => { + // A survey language that never became an app locale must not throw. + expect(getDateFnsLocale().code).toBe("en-US"); + expect(getDateFnsLocale("").code).toBe("en-US"); + expect(getDateFnsLocale("xx-YY").code).toBe("en-US"); + expect(getDateFnsLocale("uz").code).toBe("en-US"); + }); + + test("accepts a bare language tag without a region", () => { + expect(getDateFnsLocale("de").code).toBe("de"); + expect(getDateFnsLocale("ja").code).toBe("ja"); + }); +}); diff --git a/apps/web/lib/utils/datetime.ts b/apps/web/lib/utils/datetime.ts index 3f6f744b4c1c..fd8d4a7117c7 100644 --- a/apps/web/lib/utils/datetime.ts +++ b/apps/web/lib/utils/datetime.ts @@ -1,3 +1,6 @@ +import { type Locale } from "date-fns"; +import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, tr, zhCN, zhTW } from "date-fns/locale"; + const DEFAULT_LOCALE = "en-US"; const DEFAULT_DATE_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = { @@ -84,3 +87,70 @@ export const getFormattedDateTimeString = (date: Date, timeZone: string = "UTC") return new Intl.DateTimeFormat("en-CA", { ...options, timeZone: "UTC" }).format(date).replace(",", ""); } }; + +/** + * Maps an app locale code to the date-fns locale the calendar needs for month names, weekday headers + * and the first day of the week. + * + * `Intl` (which the formatters above use) takes the BCP 47 tag directly, but `react-day-picker` wants a + * date-fns `Locale` object, so the app's locale codes have to be mapped explicitly. The keys mirror + * `apps/web/locales/*.json`; anything else — including a survey language code that never became an app + * locale — falls back to en-US rather than throwing. + */ +export const getDateFnsLocale = (localeCode?: string): Locale => { + if (!localeCode) return enUS; + + const normalized = localeCode.toLowerCase(); + + // Script-and-region-specific tags first: these do not survive being cut down to a base language + // (pt-BR and pt-PT differ, and zh-Hans/zh-Hant are different scripts, not different regions). + if (normalized.startsWith("pt-br")) return ptBR; + if (normalized.startsWith("pt-pt")) return pt; + if (normalized.startsWith("zh-hans") || normalized === "zh-cn") return zhCN; + if (normalized.startsWith("zh-hant") || normalized === "zh-tw" || normalized === "zh-hk") return zhTW; + + const localeMap: Record = { + de, + en: enUS, + es, + fr, + hu, + ja, + nl, + pt: ptBR, // Bare "pt" is ambiguous; Brazilian is the larger audience and matches the survey packages. + ro, + ru, + sv, + tr, + zh: zhCN, + }; + + return localeMap[normalized.split("-")[0]] ?? enUS; +}; + +/** + * Serialises a local calendar day as `yyyy-MM-dd`. + * + * Machine-facing on purpose — this is the value chart and dashboard time filters hand to Cube — so it + * stays non-localized. Deliberately not `toISOString().slice(0, 10)`: that converts to UTC first, so + * a user east of UTC would emit tomorrow's date and one west of it yesterday's. + */ +export const formatLocalDay = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +}; + +/** + * Parses a `yyyy-MM-dd` day back to local midnight — the inverse of `formatLocalDay`. + * + * `new Date("2026-08-05")` parses as UTC midnight, which displays (and re-emits) a day earlier for + * anyone west of UTC, so the value is rebuilt from its parts to keep the round trip symmetric. + */ +export const parseLocalDay = (value: string): Date => { + const [year, month, day] = value.split("-").map(Number); + + return new Date(year, month - 1, day); +}; diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index dbf68cadc3db..5b14b8bbad3a 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -211,6 +211,7 @@ "choose_organization": "Organisation auswählen", "choose_workspace": "Workspace auswählen", "clear_all": "Alles löschen", + "clear_date": "Datum löschen", "clear_filters": "Filter löschen", "clear_selection": "Auswahl aufheben", "click": "Klick", @@ -415,6 +416,7 @@ "phone": "Telefon", "photo_by": "Foto von", "pick_a_date": "Wähle ein Datum", + "pick_a_date_range": "Wähle einen Zeitraum", "picture": "Bild", "placeholder": "Platzhalter", "please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "Traurigkeit", "emotion_value_surprise": "Überraschung", "enable_time_dimension": "Zeitdimension aktivieren", - "end_date": "Enddatum", "enter_a_name_for_your_chart": "Gib einen Namen für dein Diagramm ein, um es zu speichern.", "enter_value": "Wert eingeben", "equals": "gleich", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "Sehr negativ", "sentiment_value_very_positive": "Sehr positiv", "showing_first_n_of": "Zeige die ersten {n} von {count} Zeilen", - "start_date": "Startdatum", "time_dimension": "Zeitdimension", "time_dimension_title": "Zeitbasierte Gruppierung hinzufügen", "time_dimension_title_range_only": "Datumsbereichsfilter hinzufügen", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "Die Anzahl der von den Befragten abgeschlossenen Quoten.", "reset_survey": "Umfrage zurücksetzen", "reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.", + "select_first_date": "Wähle das Startdatum", + "select_last_date": "Wähle das Enddatum", "selected_responses_csv": "Ausgewählte Antworten (CSV)", "selected_responses_excel": "Ausgewählte Antworten (Excel)", "setup_integrations": "Integrationen einrichten", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index bd40fd7d34c7..8183cfaaa1b8 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -211,6 +211,7 @@ "choose_organization": "Choose organization", "choose_workspace": "Choose workspace", "clear_all": "Clear all", + "clear_date": "Clear date", "clear_filters": "Clear filters", "clear_selection": "Clear selection", "click": "Click", @@ -415,6 +416,7 @@ "phone": "Phone", "photo_by": "Photo by", "pick_a_date": "Pick a date", + "pick_a_date_range": "Pick a date range", "picture": "Picture", "placeholder": "Placeholder", "please_select_at_least_one_survey": "Please select at least one survey", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "Sadness", "emotion_value_surprise": "Surprise", "enable_time_dimension": "Enable Time Dimension", - "end_date": "End date", "enter_a_name_for_your_chart": "Enter a name for your chart to save it.", "enter_value": "Enter value", "equals": "equals", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "Very negative", "sentiment_value_very_positive": "Very positive", "showing_first_n_of": "Showing first {n} of {count} rows", - "start_date": "Start date", "time_dimension": "Time Dimension", "time_dimension_title": "Add time-based grouping", "time_dimension_title_range_only": "Add a date range filter", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "The number of quotas completed by the respondents.", "reset_survey": "Reset survey", "reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.", + "select_first_date": "Select first date", + "select_last_date": "Select last date", "selected_responses_csv": "Selected responses (CSV)", "selected_responses_excel": "Selected responses (Excel)", "setup_integrations": "Set up integrations", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index c72f9c803c69..038833c53aa3 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -211,6 +211,7 @@ "choose_organization": "Elegir organización", "choose_workspace": "Elegir espacio de trabajo", "clear_all": "Borrar todo", + "clear_date": "Borrar fecha", "clear_filters": "Borrar filtros", "clear_selection": "Borrar selección", "click": "Clic", @@ -415,6 +416,7 @@ "phone": "Teléfono", "photo_by": "Foto de", "pick_a_date": "Elige una fecha", + "pick_a_date_range": "Elige un rango de fechas", "picture": "Imagen", "placeholder": "Marcador de posición", "please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "Tristeza", "emotion_value_surprise": "Sorpresa", "enable_time_dimension": "Activar dimensión temporal", - "end_date": "Fecha de finalización", "enter_a_name_for_your_chart": "Introduce un nombre para tu gráfico para guardarlo.", "enter_value": "Introduce un valor", "equals": "es igual a", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "Muy negativo", "sentiment_value_very_positive": "Muy positivo", "showing_first_n_of": "Mostrando las primeras {n} de {count} filas", - "start_date": "Fecha de inicio", "time_dimension": "Dimensión temporal", "time_dimension_title": "Añadir agrupación temporal", "time_dimension_title_range_only": "Añadir un filtro de rango de fechas", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "El número de cuotas completadas por los encuestados.", "reset_survey": "Reiniciar encuesta", "reset_survey_warning": "Reiniciar una encuesta elimina todas las respuestas y visualizaciones asociadas a esta encuesta. Esto no se puede deshacer.", + "select_first_date": "Selecciona la primera fecha", + "select_last_date": "Selecciona la última fecha", "selected_responses_csv": "Respuestas seleccionadas (CSV)", "selected_responses_excel": "Respuestas seleccionadas (Excel)", "setup_integrations": "Configurar integraciones", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 34e105c12522..b7dc57599a8b 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -211,6 +211,7 @@ "choose_organization": "Choisir l'organisation", "choose_workspace": "Choisir un espace de travail", "clear_all": "Tout effacer", + "clear_date": "Effacer la date", "clear_filters": "Effacer les filtres", "clear_selection": "Effacer la sélection", "click": "Cliquez", @@ -415,6 +416,7 @@ "phone": "Téléphone", "photo_by": "Photo par", "pick_a_date": "Choisissez une date", + "pick_a_date_range": "Choisis une plage de dates", "picture": "Photo", "placeholder": "Remplaçant", "please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "Tristesse", "emotion_value_surprise": "Surprise", "enable_time_dimension": "Activer la dimension temporelle", - "end_date": "Date de fin", "enter_a_name_for_your_chart": "Saisissez un nom pour votre graphique afin de l'enregistrer.", "enter_value": "Saisissez une valeur", "equals": "égal", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "Très négatif", "sentiment_value_very_positive": "Très positif", "showing_first_n_of": "Affichage des {n} premières lignes sur {count}", - "start_date": "Date de début", "time_dimension": "Dimension temporelle", "time_dimension_title": "Ajouter un groupement temporel", "time_dimension_title_range_only": "Ajouter un filtre de plage de dates", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "Le nombre de quotas complétés par les répondants.", "reset_survey": "Réinitialiser l'enquête", "reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.", + "select_first_date": "Sélectionne la première date", + "select_last_date": "Sélectionne la dernière date", "selected_responses_csv": "Réponses sélectionnées (CSV)", "selected_responses_excel": "Réponses sélectionnées (Excel)", "setup_integrations": "Configurer les intégrations", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 473b80f0c005..2a3a14773e6c 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -211,6 +211,7 @@ "choose_organization": "Szervezet kiválasztása", "choose_workspace": "Munkaterület kiválasztása", "clear_all": "Összes törlése", + "clear_date": "Dátum törlése", "clear_filters": "Szűrők törlése", "clear_selection": "Kijelölés törlése", "click": "Kattintás", @@ -415,6 +416,7 @@ "phone": "Telefon", "photo_by": "Fénykép készítője", "pick_a_date": "Dátum kiválasztása", + "pick_a_date_range": "Válasszon dátumtartományt", "picture": "Fénykép", "placeholder": "Helykitöltő", "please_select_at_least_one_survey": "Válasszon legalább egy kérdőívet", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "Szomorúság", "emotion_value_surprise": "Meglepetés", "enable_time_dimension": "Idődimenzió engedélyezése", - "end_date": "Befejezési dátum", "enter_a_name_for_your_chart": "Adjon nevet a diagramnak a mentéséhez.", "enter_value": "Érték megadása", "equals": "egyenlő", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "Nagyon negatív", "sentiment_value_very_positive": "Nagyon pozitív", "showing_first_n_of": "Első {n} / {count} sor megjelenítése", - "start_date": "Kezdési dátum", "time_dimension": "Idődimenzió", "time_dimension_title": "Időalapú csoportosítás hozzáadása", "time_dimension_title_range_only": "Dátumtartomány-szűrő hozzáadása", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "A válaszadók által teljesített kvóták száma.", "reset_survey": "Kérdőív visszaállítása", "reset_survey_warning": "Egy kérdőív visszaállítása eltávolítja a kérdőívhez hozzárendelt összes választ és megjelenítést. Ezt nem lehet visszavonni.", + "select_first_date": "Válassza ki a kezdő dátumot", + "select_last_date": "Válassza ki a záró dátumot", "selected_responses_csv": "Kijelölt válaszok (CSV)", "selected_responses_excel": "Kijelölt válaszok (Excel)", "setup_integrations": "Integrációk beállítása", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 6e791af2b05e..9880321c659d 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -211,6 +211,7 @@ "choose_organization": "組織を選択", "choose_workspace": "ワークスペースを選択", "clear_all": "すべてクリア", + "clear_date": "日付をクリア", "clear_filters": "フィルターをクリア", "clear_selection": "選択をクリア", "click": "クリック", @@ -415,6 +416,7 @@ "phone": "電話", "photo_by": "撮影者", "pick_a_date": "日付を選択", + "pick_a_date_range": "期間を選択", "picture": "写真", "placeholder": "プレースホルダー", "please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "悲しみ", "emotion_value_surprise": "驚き", "enable_time_dimension": "時間ディメンションを有効化", - "end_date": "終了日", "enter_a_name_for_your_chart": "チャートを保存するには名前を入力してください。", "enter_value": "値を入力", "equals": "と等しい", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "非常にネガティブ", "sentiment_value_very_positive": "非常にポジティブ", "showing_first_n_of": "{count} 行中、最初の {n} 行を表示しています", - "start_date": "開始日", "time_dimension": "時間ディメンション", "time_dimension_title": "時間ベースのグループ化を追加", "time_dimension_title_range_only": "日付範囲フィルターを追加", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "回答者 によって 完了 した 定員 の 数。", "reset_survey": "フォームをリセット", "reset_survey_warning": "フォームをリセットすると、このフォームに関連付けられているすべての回答と表示が削除されます。この操作は元に戻せません。", + "select_first_date": "開始日を選択", + "select_last_date": "終了日を選択", "selected_responses_csv": "選択した回答 (CSV)", "selected_responses_excel": "選択した回答 (Excel)", "setup_integrations": "連携を設定", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 11d98980b8e3..c5b102074015 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -211,6 +211,7 @@ "choose_organization": "Kies organisatie", "choose_workspace": "Kies werkruimte", "clear_all": "Alles wissen", + "clear_date": "Datum wissen", "clear_filters": "Wis filters", "clear_selection": "Duidelijke selectie", "click": "Klik", @@ -415,6 +416,7 @@ "phone": "Telefoon", "photo_by": "Foto door", "pick_a_date": "Kies een datum", + "pick_a_date_range": "Kies een datumbereik", "picture": "Afbeelding", "placeholder": "Tijdelijke aanduiding", "please_select_at_least_one_survey": "Selecteer ten minste één enquête", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "Verdriet", "emotion_value_surprise": "Verrassing", "enable_time_dimension": "Tijdsdimensie inschakelen", - "end_date": "Einddatum", "enter_a_name_for_your_chart": "Voer een naam in voor je diagram om het op te slaan.", "enter_value": "Voer waarde in", "equals": "is gelijk aan", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "Zeer negatief", "sentiment_value_very_positive": "Zeer positief", "showing_first_n_of": "Toont eerste {n} van {count} rijen", - "start_date": "Startdatum", "time_dimension": "Tijdsdimensie", "time_dimension_title": "Tijdgebaseerde groepering toevoegen", "time_dimension_title_range_only": "Voeg een datumbereikfilter toe", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "Het aantal quota dat door de respondenten is voltooid.", "reset_survey": "Enquête opnieuw instellen", "reset_survey_warning": "Als u een enquête opnieuw instelt, worden alle reacties en weergaven verwijderd die aan deze enquête zijn gekoppeld. Dit kan niet ongedaan worden gemaakt.", + "select_first_date": "Selecteer eerste datum", + "select_last_date": "Selecteer laatste datum", "selected_responses_csv": "Geselecteerde reacties (CSV)", "selected_responses_excel": "Geselecteerde antwoorden (Excel)", "setup_integrations": "Integraties instellen", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 59d6d9a5eeeb..119d0bfe33e4 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -211,6 +211,7 @@ "choose_organization": "Escolher organização", "choose_workspace": "Escolher workspace", "clear_all": "Limpar tudo", + "clear_date": "Limpar data", "clear_filters": "Limpar filtros", "clear_selection": "Limpar seleção", "click": "Clica", @@ -415,6 +416,7 @@ "phone": "Celular", "photo_by": "Foto por", "pick_a_date": "Escolhe uma data", + "pick_a_date_range": "Escolha um intervalo de datas", "picture": "Imagem", "placeholder": "Espaço reservado", "please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "Tristeza", "emotion_value_surprise": "Surpresa", "enable_time_dimension": "Ativar dimensão de tempo", - "end_date": "Data final", "enter_a_name_for_your_chart": "Digite um nome para o seu gráfico para salvá-lo.", "enter_value": "Digite o valor", "equals": "igual", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "Muito negativo", "sentiment_value_very_positive": "Muito positivo", "showing_first_n_of": "Mostrando as primeiras {n} de {count} linhas", - "start_date": "Data inicial", "time_dimension": "Dimensão temporal", "time_dimension_title": "Adicionar agrupamento por tempo", "time_dimension_title_range_only": "Adicionar um filtro de intervalo de datas", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "Número de cotas preenchidas pelos respondentes.", "reset_survey": "Redefinir pesquisa", "reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.", + "select_first_date": "Selecione a data inicial", + "select_last_date": "Selecione a data final", "selected_responses_csv": "Respostas selecionadas (CSV)", "selected_responses_excel": "Respostas selecionadas (Excel)", "setup_integrations": "Configurar integrações", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 1b99ef5e48d4..4eaaff9f0b84 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -211,6 +211,7 @@ "choose_organization": "Escolher organização", "choose_workspace": "Escolher espaço de trabalho", "clear_all": "Limpar tudo", + "clear_date": "Limpar data", "clear_filters": "Limpar filtros", "clear_selection": "Limpar seleção", "click": "Clique", @@ -415,6 +416,7 @@ "phone": "Telefone", "photo_by": "Foto de", "pick_a_date": "Escolha uma data", + "pick_a_date_range": "Escolhe um intervalo de datas", "picture": "Imagem", "placeholder": "Espaço reservado", "please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "Tristeza", "emotion_value_surprise": "Surpresa", "enable_time_dimension": "Ativar dimensão temporal", - "end_date": "Data de fim", "enter_a_name_for_your_chart": "Introduza um nome para o seu gráfico para o guardar.", "enter_value": "Introduza o valor", "equals": "igual", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "Muito negativo", "sentiment_value_very_positive": "Muito positivo", "showing_first_n_of": "A mostrar as primeiras {n} de {count} linhas", - "start_date": "Data de início", "time_dimension": "Dimensão temporal", "time_dimension_title": "Adicionar agrupamento temporal", "time_dimension_title_range_only": "Adicionar um filtro de intervalo de datas", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "O número de quotas concluídas pelos respondentes.", "reset_survey": "Reiniciar inquérito", "reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.", + "select_first_date": "Seleciona a primeira data", + "select_last_date": "Seleciona a última data", "selected_responses_csv": "Respostas selecionadas (CSV)", "selected_responses_excel": "Respostas selecionadas (Excel)", "setup_integrations": "Configurar integrações", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index d8d813e39229..61cbddc10f00 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -211,6 +211,7 @@ "choose_organization": "Alege organizația", "choose_workspace": "Alege workspace", "clear_all": "Șterge tot", + "clear_date": "Șterge data", "clear_filters": "Curăță filtrele", "clear_selection": "Șterge selecția", "click": "Click", @@ -415,6 +416,7 @@ "phone": "Telefon", "photo_by": "Fotografie de", "pick_a_date": "Alege o dată", + "pick_a_date_range": "Alege un interval de timp", "picture": "Poză", "placeholder": "Marcaj substituent", "please_select_at_least_one_survey": "Vă rugăm să selectați cel puțin un sondaj", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "Tristețe", "emotion_value_surprise": "Surpriză", "enable_time_dimension": "Activează dimensiunea de timp", - "end_date": "Data de sfârșit", "enter_a_name_for_your_chart": "Introdu un nume pentru grafic ca să îl salvezi.", "enter_value": "Introdu valoarea", "equals": "egal", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "Foarte negativ", "sentiment_value_very_positive": "Foarte pozitiv", "showing_first_n_of": "Se afișează primele {n} din {count} rânduri", - "start_date": "Data de început", "time_dimension": "Dimensiune temporală", "time_dimension_title": "Adaugă grupare pe bază de timp", "time_dimension_title_range_only": "Adaugă un filtru de interval de date", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "Numărul de cote completate de respondenți.", "reset_survey": "Resetează chestionarul", "reset_survey_warning": "Resetarea unui sondaj elimină toate răspunsurile și afișajele asociate cu acest sondaj. Aceasta nu poate fi anulată.", + "select_first_date": "Selectează prima dată", + "select_last_date": "Selectează ultima dată", "selected_responses_csv": "Răspunsuri selectate (CSV)", "selected_responses_excel": "Răspunsuri selectate (Excel)", "setup_integrations": "Configurează integrările", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 18ee56fd624d..799105c71a78 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -211,6 +211,7 @@ "choose_organization": "Выберите организацию", "choose_workspace": "Выбрать рабочее пространство", "clear_all": "Очистить всё", + "clear_date": "Очистить дату", "clear_filters": "Сбросить фильтры", "clear_selection": "Снять выделение", "click": "Клик", @@ -415,6 +416,7 @@ "phone": "Телефон", "photo_by": "Фото:", "pick_a_date": "Выберите дату", + "pick_a_date_range": "Выберите диапазон дат", "picture": "Изображение", "placeholder": "Заполнитель", "please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "Грусть", "emotion_value_surprise": "Удивление", "enable_time_dimension": "Включить временное измерение", - "end_date": "Дата окончания", "enter_a_name_for_your_chart": "Введи название для графика, чтобы сохранить его.", "enter_value": "Введи значение", "equals": "равно", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "Очень негативный", "sentiment_value_very_positive": "Очень позитивный", "showing_first_n_of": "Показаны первые {n} из {count} строк", - "start_date": "Дата начала", "time_dimension": "Временное измерение", "time_dimension_title": "Добавить группировку по времени", "time_dimension_title_range_only": "Добавить фильтр по диапазону дат", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "Количество квот, выполненных респондентами.", "reset_survey": "Сбросить опрос", "reset_survey_warning": "Сброс опроса удаляет все ответы и связанные с этим опросом отображения. Это действие необратимо.", + "select_first_date": "Выберите начальную дату", + "select_last_date": "Выберите конечную дату", "selected_responses_csv": "Выбранные ответы (CSV)", "selected_responses_excel": "Выбранные ответы (Excel)", "setup_integrations": "Настроить интеграции", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index c5d425b794e5..82534a53f2d5 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -211,6 +211,7 @@ "choose_organization": "Välj organisation", "choose_workspace": "Välj arbetsyta", "clear_all": "Rensa allt", + "clear_date": "Rensa datum", "clear_filters": "Rensa filter", "clear_selection": "Rensa urval", "click": "Klicka", @@ -415,6 +416,7 @@ "phone": "Telefon", "photo_by": "Foto av", "pick_a_date": "Välj ett datum", + "pick_a_date_range": "Välj ett datumintervall", "picture": "Bild", "placeholder": "Platshållare", "please_select_at_least_one_survey": "Vänligen välj minst en enkät", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "Sorg", "emotion_value_surprise": "Överraskning", "enable_time_dimension": "Aktivera tidsdimension", - "end_date": "Slutdatum", "enter_a_name_for_your_chart": "Ange ett namn för ditt diagram för att spara det.", "enter_value": "Ange värde", "equals": "är lika med", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "Mycket negativ", "sentiment_value_very_positive": "Mycket positiv", "showing_first_n_of": "Visar de första {n} av {count} raderna", - "start_date": "Startdatum", "time_dimension": "Tidsdimension", "time_dimension_title": "Lägg till tidsbaserad gruppering", "time_dimension_title_range_only": "Lägg till ett datumintervallfilter", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "Antalet kvoter som slutförts av respondenterna.", "reset_survey": "Återställ enkät", "reset_survey_warning": "Att återställa en enkät tar bort alla svar och visningar kopplade till denna enkät. Detta kan inte ångras.", + "select_first_date": "Välj startdatum", + "select_last_date": "Välj slutdatum", "selected_responses_csv": "Valda svar (CSV)", "selected_responses_excel": "Valda svar (Excel)", "setup_integrations": "Konfigurera integrationer", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 7b70d5c998a0..f57c2ea42bb9 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -211,6 +211,7 @@ "choose_organization": "Organizasyon seç", "choose_workspace": "Çalışma alanı seç", "clear_all": "Tümünü temizle", + "clear_date": "Tarihi temizle", "clear_filters": "Filtreleri temizle", "clear_selection": "Seçimi temizle", "click": "Tıklama", @@ -415,6 +416,7 @@ "phone": "Telefon", "photo_by": "Fotoğraf:", "pick_a_date": "Tarih seçin", + "pick_a_date_range": "Bir tarih aralığı seç", "picture": "Resim", "placeholder": "Yer tutucu", "please_select_at_least_one_survey": "Lütfen en az bir survey seçin", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "Üzüntü", "emotion_value_surprise": "Şaşkınlık", "enable_time_dimension": "Zaman Boyutunu Etkinleştir", - "end_date": "Bitiş tarihi", "enter_a_name_for_your_chart": "Grafiğini kaydetmek için bir isim gir.", "enter_value": "Değer gir", "equals": "eşittir", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "Çok olumsuz", "sentiment_value_very_positive": "Çok olumlu", "showing_first_n_of": "{count} satırdan ilk {n} tanesi gösteriliyor", - "start_date": "Başlangıç tarihi", "time_dimension": "Zaman Boyutu", "time_dimension_title": "Zaman tabanlı gruplama ekle", "time_dimension_title_range_only": "Tarih aralığı filtresi ekle", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "Katılımcılar tarafından tamamlanan kota sayısı.", "reset_survey": "Anketi sıfırla", "reset_survey_warning": "Bir anketi sıfırlamak, bu anketle ilişkili tüm yanıtları ve gösterimleri kaldırır. Bu işlem geri alınamaz.", + "select_first_date": "İlk tarihi seç", + "select_last_date": "Son tarihi seç", "selected_responses_csv": "Seçili yanıtlar (CSV)", "selected_responses_excel": "Seçili yanıtlar (Excel)", "setup_integrations": "Entegrasyonları ayarla", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index c3fe20ddd28f..24a2a655c022 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -211,6 +211,7 @@ "choose_organization": "选择 组织", "choose_workspace": "选择工作区", "clear_all": "清除所有", + "clear_date": "清除日期", "clear_filters": "清除 过滤器", "clear_selection": "清除 选择", "click": "点击", @@ -415,6 +416,7 @@ "phone": "电话", "photo_by": "摄影:", "pick_a_date": "选择 日期", + "pick_a_date_range": "选择日期范围", "picture": "图片", "placeholder": "占位符", "please_select_at_least_one_survey": "请选择至少 一个调查", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "悲伤", "emotion_value_surprise": "惊讶", "enable_time_dimension": "启用时间维度", - "end_date": "结束日期", "enter_a_name_for_your_chart": "请输入图表名称以保存。", "enter_value": "输入值", "equals": "等于", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "非常负面", "sentiment_value_very_positive": "非常正面", "showing_first_n_of": "显示前 {n} 行,共 {count} 行", - "start_date": "开始日期", "time_dimension": "时间维度", "time_dimension_title": "添加基于时间的分组", "time_dimension_title_range_only": "添加日期范围筛选", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "受访者完成的配额数量。", "reset_survey": "重置 调查", "reset_survey_warning": "重置 一个调查 会移除与 此调查 相关 的 所有响应 和 展示 。此操作 不能 撤销 。", + "select_first_date": "选择开始日期", + "select_last_date": "选择结束日期", "selected_responses_csv": "选定 反馈 (CSV)", "selected_responses_excel": "选定 反馈 (Excel)", "setup_integrations": "设置集成", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 19dc416421ec..29bde03f999a 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -211,6 +211,7 @@ "choose_organization": "選擇組織", "choose_workspace": "選擇工作區", "clear_all": "全部清除", + "clear_date": "清除日期", "clear_filters": "清除篩選器", "clear_selection": "清除選取", "click": "點擊", @@ -415,6 +416,7 @@ "phone": "電話", "photo_by": "照片來源:", "pick_a_date": "選擇日期", + "pick_a_date_range": "選擇日期範圍", "picture": "圖片", "placeholder": "提示文字", "please_select_at_least_one_survey": "請選擇至少一個問卷", @@ -1711,7 +1713,6 @@ "emotion_value_sadness": "悲傷", "emotion_value_surprise": "驚訝", "enable_time_dimension": "啟用時間維度", - "end_date": "結束日期", "enter_a_name_for_your_chart": "請輸入圖表名稱以儲存。", "enter_value": "請輸入數值", "equals": "等於", @@ -1851,7 +1852,6 @@ "sentiment_value_very_negative": "非常負面", "sentiment_value_very_positive": "非常正面", "showing_first_n_of": "顯示 {count} 列中的前 {n} 列", - "start_date": "開始日期", "time_dimension": "時間維度", "time_dimension_title": "新增基於時間的分組", "time_dimension_title_range_only": "新增日期範圍篩選條件", @@ -3902,6 +3902,8 @@ "quotas_completed_tooltip": "受訪者已完成的配額數量。", "reset_survey": "重設問卷", "reset_survey_warning": "重設問卷會移除與此問卷相關的所有回應與曝光紀錄,且無法復原。", + "select_first_date": "選擇開始日期", + "select_last_date": "選擇結束日期", "selected_responses_csv": "選擇的回應 (CSV)", "selected_responses_excel": "選擇的回應 (Excel)", "setup_integrations": "設定整合", diff --git a/apps/web/modules/ee/analysis/charts/components/filter-date-input.tsx b/apps/web/modules/ee/analysis/charts/components/filter-date-input.tsx index ac68d70190c8..fedaa6ad0506 100644 --- a/apps/web/modules/ee/analysis/charts/components/filter-date-input.tsx +++ b/apps/web/modules/ee/analysis/charts/components/filter-date-input.tsx @@ -1,63 +1,32 @@ "use client"; -import { format, isValid, parseISO } from "date-fns"; -import { CalendarIcon } from "lucide-react"; -import { useState } from "react"; -import Calendar from "react-calendar"; +import { isValid, parseISO } from "date-fns"; import { useTranslation } from "react-i18next"; -import { formatDateForDisplay } from "@/lib/utils/datetime"; -import { Button } from "@/modules/ui/components/button"; -import "@/modules/ui/components/date-picker/styles.css"; -import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover"; +import { formatLocalDay } from "@/lib/utils/datetime"; +import { DatePicker } from "@/modules/ui/components/date-picker"; interface FilterDateInputProps { value: string; onChange: (value: string | null) => void; } -const DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = { day: "numeric", month: "short", year: "numeric" }; - /** * Date-picker filter input for time-type dimensions (e.g. Collected At, Value (Date)). * Stores the picked day as a `yyyy-MM-dd` string (the machine value Cube expects for time - * filters) while displaying it via the shared, locale-aware formatter. + * filters) while the shared picker renders it locale-aware. */ export function FilterDateInput({ value, onChange }: Readonly) { - const { t, i18n } = useTranslation(); - const [open, setOpen] = useState(false); - const locale = i18n.language; + const { i18n } = useTranslation(); const parsed = value ? parseISO(value) : null; - const selectedDate = parsed && isValid(parsed) ? parsed : undefined; + const selectedDate = parsed && isValid(parsed) ? parsed : null; return ( - - - - - - { - const date = next instanceof Date ? next : null; - if (date) { - onChange(format(date, "yyyy-MM-dd")); - setOpen(false); - } - }} - /> - - + onChange(formatLocalDay(date))} + /> ); } diff --git a/apps/web/modules/ee/analysis/charts/components/time-dimension-panel.tsx b/apps/web/modules/ee/analysis/charts/components/time-dimension-panel.tsx index 73452c46316b..dedf2450ddbf 100644 --- a/apps/web/modules/ee/analysis/charts/components/time-dimension-panel.tsx +++ b/apps/web/modules/ee/analysis/charts/components/time-dimension-panel.tsx @@ -1,9 +1,6 @@ "use client"; -import { format } from "date-fns"; -import { CalendarIcon } from "lucide-react"; import { useState } from "react"; -import Calendar from "react-calendar"; import { useTranslation } from "react-i18next"; import type { TimeDimensionConfig } from "@/modules/ee/analysis/lib/query-builder"; import { @@ -15,9 +12,8 @@ import { getTranslatedGranularityLabel, } from "@/modules/ee/analysis/lib/schema-definition"; import { Button } from "@/modules/ui/components/button"; -import "@/modules/ui/components/date-picker/styles.css"; +import { DateRangePicker } from "@/modules/ui/components/date-picker"; import { Label } from "@/modules/ui/components/label"; -import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover"; import { Select, SelectContent, @@ -51,7 +47,7 @@ export function TimeDimensionPanel({ hideTitle = false, hideGranularity = false, }: Readonly) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [dateRangeType, setDateRangeType] = useState<"preset" | "custom">( timeDimension && typeof timeDimension.dateRange === "string" ? "preset" : "custom" ); @@ -208,64 +204,18 @@ export function TimeDimensionPanel({ {dateRangeType === "custom" && ( -
- - - - - - { - const date = value instanceof Date ? value : new Date(); - setCustomStartDate(date); - const end = customEndDate ?? date; - if (timeDimension) { - onTimeDimensionChange({ - ...timeDimension, - dateRange: [date, end], - }); - } - if (!customEndDate) setCustomEndDate(end); - }} - value={customStartDate || undefined} - /> - - - - - - - - - { - const date = value instanceof Date ? value : new Date(); - setCustomEndDate(date); - const start = customStartDate ?? date; - if (timeDimension) { - onTimeDimensionChange({ - ...timeDimension, - dateRange: [start, date], - }); - } - if (!customStartDate) setCustomStartDate(start); - }} - value={customEndDate || undefined} - minDate={customStartDate || undefined} - /> - - -
+ { + setCustomStartDate(from); + setCustomEndDate(to); + if (timeDimension) { + onTimeDimensionChange({ ...timeDimension, dateRange: [from, to] }); + } + }} + /> )} diff --git a/apps/web/modules/ee/analysis/dashboards/components/dashboard-date-filter.tsx b/apps/web/modules/ee/analysis/dashboards/components/dashboard-date-filter.tsx index 62ab66f1c0d6..1f7322b1f329 100644 --- a/apps/web/modules/ee/analysis/dashboards/components/dashboard-date-filter.tsx +++ b/apps/web/modules/ee/analysis/dashboards/components/dashboard-date-filter.tsx @@ -1,16 +1,11 @@ "use client"; -import { format } from "date-fns"; -import { CalendarIcon } from "lucide-react"; import { useEffect, useState } from "react"; -import Calendar from "react-calendar"; import { useTranslation } from "react-i18next"; -import { formatDateForDisplay } from "@/lib/utils/datetime"; +import { formatLocalDay, parseLocalDay } from "@/lib/utils/datetime"; import { DASHBOARD_DATE_PRESETS } from "@/modules/ee/analysis/lib/date-presets"; import { getTranslatedDatePresetLabel } from "@/modules/ee/analysis/lib/schema-definition"; -import { Button } from "@/modules/ui/components/button"; -import "@/modules/ui/components/date-picker/styles.css"; -import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover"; +import { DateRangePicker } from "@/modules/ui/components/date-picker"; import { Select, SelectContent, @@ -30,25 +25,16 @@ interface DashboardDateFilterProps { onChange: (filter: TDashboardDateFilter | null) => void; } -// Custom-range bounds serialize with the local `format(date, "yyyy-MM-dd")` below, so they must be -// parsed back as local calendar days too. `new Date("YYYY-MM-DD")` parses as UTC midnight, which -// shows (and re-emits) a day earlier for anyone west of UTC — parse the parts as local instead to -// keep the round trip symmetric. -const parseLocalDate = (iso: string): Date => { - const [year, month, day] = iso.split("-").map(Number); - return new Date(year, month - 1, day); -}; - export const DashboardDateFilter = ({ value, onChange }: Readonly) => { const { t, i18n } = useTranslation(); - const locale = i18n.resolvedLanguage ?? "en-US"; + const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US"; const [isCustomMode, setIsCustomMode] = useState(value?.type === "custom"); const [customStart, setCustomStart] = useState( - value?.type === "custom" ? parseLocalDate(value.range[0]) : null + value?.type === "custom" ? parseLocalDay(value.range[0]) : null ); const [customEnd, setCustomEnd] = useState( - value?.type === "custom" ? parseLocalDate(value.range[1]) : null + value?.type === "custom" ? parseLocalDay(value.range[1]) : null ); // Query-only navigation (back/forward, or restoring a persisted filter) keeps this component @@ -57,8 +43,8 @@ export const DashboardDateFilter = ({ value, onChange }: Readonly { if (value?.type === "custom") { setIsCustomMode(true); - setCustomStart(parseLocalDate(value.range[0])); - setCustomEnd(parseLocalDate(value.range[1])); + setCustomStart(parseLocalDay(value.range[0])); + setCustomEnd(parseLocalDay(value.range[1])); } else { setIsCustomMode(false); } @@ -75,7 +61,7 @@ export const DashboardDateFilter = ({ value, onChange }: Readonly { if (start && end) { - onChange({ type: "custom", range: [format(start, "yyyy-MM-dd"), format(end, "yyyy-MM-dd")] }); + onChange({ type: "custom", range: [formatLocalDay(start), formatLocalDay(end)] }); } }; @@ -121,50 +107,16 @@ export const DashboardDateFilter = ({ value, onChange }: Readonly {selectValue === CUSTOM_VALUE && ( -
- - - - - - { - const date = v instanceof Date ? v : new Date(); - setCustomStart(date); - emitCustom(date, customEnd); - }} - value={customStart || undefined} - /> - - - - - - - - - { - const date = v instanceof Date ? v : new Date(); - setCustomEnd(date); - emitCustom(customStart, date); - }} - value={customEnd || undefined} - minDate={customStart || undefined} - /> - - -
+ { + setCustomStart(from); + setCustomEnd(to); + emitCustom(from, to); + }} + /> )} ); diff --git a/apps/web/modules/survey/editor/components/add-element-button.tsx b/apps/web/modules/survey/editor/components/add-element-button.tsx index 1e44becb40bf..5db8a39ad5aa 100644 --- a/apps/web/modules/survey/editor/components/add-element-button.tsx +++ b/apps/web/modules/survey/editor/components/add-element-button.tsx @@ -97,8 +97,10 @@ export const AddElementButton = ({ addElement, workspace, isCxMode }: AddElement open the element picker at all. Letting Radix render its own ` - ) : ( - - )} + - + onDateChange(date as Date)} - minDate={effectiveMinDate} - className="border-0!" - tileClassName={({ date }: { date: Date }) => { - const baseClass = - "hover:fb-bg-input-bg-selected fb-rounded-custom fb-h-9 fb-p-0 fb-mt-1 fb-font-normal fb-text-heading aria-selected:fb-opacity-100 focus:fb-ring-2 focus:fb-bg-slate-200"; - const today = effectiveMinDate; - - // today's date class - if ( - date.getDate() === today.getDate() && - date.getMonth() === today.getMonth() && - date.getFullYear() === today.getFullYear() - ) { - return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-heading focus:fb-ring-2 focus:fb-bg-slate-200`; - } - // active date class - if ( - date.getDate() === value?.getDate() && - date.getMonth() === value?.getMonth() && - date.getFullYear() === value?.getFullYear() - ) { - return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-heading`; - } - - return baseClass; + defaultMonth={value ?? undefined} + selected={value ?? undefined} + disabled={getDisabledMatchers(minDate, maxDate)} + onSelect={(date) => { + if (!date) return; + onChange(date); + setIsOpen(false); }} - showNeighboringMonth={false} /> - {formattedDate && onClearDate && ( + {value && onClear && ( )} ); }; + +interface DateRangeCalendarProps { + value: TDateRangeValue; + onChange: (range: TDateRangeValue) => void; + /** + * Fires once the second bound lands, carrying the committed range. It is passed explicitly rather + * than read back from `value`, which is still the pre-click range while this fires. + */ + onComplete?: (range: TDateRangeValue) => void; + locale?: string; + minDate?: Date; + maxDate?: Date; + numberOfMonths?: number; + className?: string; +} + +/** + * The range calendar without a trigger, for hosts that already own the open/close affordance (the + * responses filter opens it from its own dropdown). + * + * Which bound the next click sets is component state and resets on mount, so a host that mounts this + * conditionally always starts a fresh range at `from`. + */ +export const DateRangeCalendar = ({ + value, + onChange, + onComplete, + locale, + minDate, + maxDate, + numberOfMonths = 2, + className, +}: Readonly) => { + const [bound, setBound] = useState("from"); + const [hoveredRange, setHoveredRange] = useState(null); + + return ( + { + const { range, nextBound, isComplete } = applyRangeClick(value, bound, triggerDate); + onChange(range); + setBound(nextBound); + setHoveredRange(null); + if (isComplete) onComplete?.(range); + }} + onDayMouseEnter={(date) => setHoveredRange(applyRangeHover(value, bound, date))} + onDayMouseLeave={() => setHoveredRange(null)} + /> + ); +}; + +interface DateRangePickerProps { + value: TDateRangeValue | null; + /** Fires only for a complete range, so a half-picked one never reaches a query. */ + onChange: (range: { from: Date; to: Date }) => void; + locale?: string; + minDate?: Date; + maxDate?: Date; + placeholder?: string; + disabled?: boolean; + triggerClassName?: string; + align?: "start" | "center" | "end"; +} + +export const DateRangePicker = ({ + value, + onChange, + locale, + minDate, + maxDate, + placeholder, + disabled, + triggerClassName, + align = "start", +}: Readonly) => { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + const [draft, setDraft] = useState({ from: value?.from, to: value?.to }); + + const label = useMemo(() => { + if (!value?.from || !value.to) return undefined; + return `${formatDateForDisplay(value.from, locale, DISPLAY_OPTIONS)} – ${formatDateForDisplay(value.to, locale, DISPLAY_OPTIONS)}`; + }, [value?.from, value?.to, locale]); + + return ( + { + // Reopening starts from the committed value, not from a range abandoned half-picked last time. + if (next) setDraft({ from: value?.from, to: value?.to }); + setIsOpen(next); + }}> + + + + + { + if (!range.from || !range.to) return; + onChange({ from: range.from, to: range.to }); + setIsOpen(false); + }} + /> + + + ); +}; diff --git a/apps/web/modules/ui/components/date-picker/lib/range.test.ts b/apps/web/modules/ui/components/date-picker/lib/range.test.ts new file mode 100644 index 000000000000..fd4dd016f6fe --- /dev/null +++ b/apps/web/modules/ui/components/date-picker/lib/range.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "vitest"; +import { + type TDateRangeValue, + addLocalDays, + applyRangeClick, + applyRangeHover, + endOfLocalDay, + startOfLocalDay, +} from "./range"; + +// Local-time constructor on purpose: the whole point of these helpers is local calendar days, so a +// `new Date("2026-03-10")` (UTC midnight) fixture would test the wrong thing west of UTC. +const day = (year: number, month: number, date: number, hours = 12) => + new Date(year, month - 1, date, hours, 30, 15, 500); + +describe("startOfLocalDay / endOfLocalDay", () => { + test("pins the local day boundaries without shifting the calendar day", () => { + const start = startOfLocalDay(day(2026, 3, 10)); + const end = endOfLocalDay(day(2026, 3, 10)); + + expect([start.getFullYear(), start.getMonth(), start.getDate()]).toEqual([2026, 2, 10]); + expect([start.getHours(), start.getMinutes(), start.getSeconds(), start.getMilliseconds()]).toEqual([ + 0, 0, 0, 0, + ]); + expect([end.getFullYear(), end.getMonth(), end.getDate()]).toEqual([2026, 2, 10]); + expect([end.getHours(), end.getMinutes(), end.getSeconds(), end.getMilliseconds()]).toEqual([ + 23, 59, 59, 999, + ]); + }); + + test("does not mutate its argument", () => { + const original = day(2026, 3, 10); + const snapshot = original.getTime(); + + startOfLocalDay(original); + endOfLocalDay(original); + + expect(original.getTime()).toBe(snapshot); + }); +}); + +describe("addLocalDays", () => { + // Northern-hemisphere transitions for both the US and EU conventions, so at least one of these is a + // 23- or 25-hour local day in most zones the app runs in. + const DST_DATES: [string, number, number, number][] = [ + ["US spring forward", 2026, 3, 8], + ["US fall back", 2026, 11, 1], + ["EU spring forward", 2026, 3, 29], + ["EU fall back", 2026, 10, 25], + ]; + + test.each(DST_DATES)("moves one calendar day forward across %s", (_label, year, month, date) => { + const start = startOfLocalDay(new Date(year, month - 1, date)); + const next = addLocalDays(start, 1); + + // The date advances by exactly one, whatever the day's length in hours. + expect(next.getDate()).toBe(new Date(year, month - 1, date + 1).getDate()); + expect(next.getMonth()).toBe(new Date(year, month - 1, date + 1).getMonth()); + }); + + test.each(DST_DATES)("moves one calendar day back across %s", (_label, year, month, date) => { + const start = endOfLocalDay(new Date(year, month - 1, date)); + const previous = addLocalDays(start, -1); + + expect(previous.getDate()).toBe(new Date(year, month - 1, date - 1).getDate()); + expect(previous.getMonth()).toBe(new Date(year, month - 1, date - 1).getMonth()); + }); + + test("does not mutate its argument", () => { + const original = day(2026, 3, 10); + const snapshot = original.getTime(); + + addLocalDays(original, 1); + + expect(original.getTime()).toBe(snapshot); + }); +}); + +describe("applyRangeClick", () => { + test("covers the whole last day so a late response on it still matches the filter", () => { + const result = applyRangeClick({ from: startOfLocalDay(day(2026, 3, 1)) }, "to", day(2026, 3, 31, 9)); + + // 09:30 was clicked, but the bound has to reach the end of that day. + expect(result.range.to?.getHours()).toBe(23); + expect(result.range.to?.getDate()).toBe(31); + expect(result.isComplete).toBe(true); + }); + + test("picking 'from' hands the next click to 'to' and does not complete the range", () => { + const result = applyRangeClick({ from: undefined }, "from", day(2026, 3, 10)); + + expect(result.nextBound).toBe("to"); + expect(result.isComplete).toBe(false); + expect(result.range.to).toBeUndefined(); + }); + + test("keeps an existing 'to' when the new 'from' still precedes it", () => { + const existingTo = endOfLocalDay(day(2026, 3, 31)); + const result = applyRangeClick( + { from: startOfLocalDay(day(2026, 3, 20)), to: existingTo }, + "from", + day(2026, 3, 5) + ); + + expect(result.range.from?.getDate()).toBe(5); + expect(result.range.to?.getTime()).toBe(existingTo.getTime()); + }); + + test("a 'from' past the current 'to' yields a one-day range at the clicked day, never an inverted one", () => { + const result = applyRangeClick( + { from: startOfLocalDay(day(2026, 3, 1)), to: endOfLocalDay(day(2026, 3, 5)) }, + "from", + day(2026, 3, 20) + ); + + expect(result.range.from?.getDate()).toBe(20); + expect(result.range.to?.getDate()).toBe(21); + expect(result.range.from!.getTime()).toBeLessThan(result.range.to!.getTime()); + }); + + test("a 'to' before the current 'from' pulls 'from' back instead of inverting", () => { + const result = applyRangeClick( + { from: startOfLocalDay(day(2026, 3, 20)), to: endOfLocalDay(day(2026, 3, 25)) }, + "to", + day(2026, 3, 5) + ); + + expect(result.range.to?.getDate()).toBe(5); + expect(result.range.from?.getDate()).toBe(4); + expect(result.range.from!.getTime()).toBeLessThan(result.range.to!.getTime()); + }); + + test("crossing a month boundary keeps the adjacent-day fallback correct", () => { + const result = applyRangeClick( + { from: startOfLocalDay(day(2026, 3, 1)), to: endOfLocalDay(day(2026, 3, 2)) }, + "from", + day(2026, 3, 31) + ); + + expect([result.range.to?.getMonth(), result.range.to?.getDate()]).toEqual([3, 1]); + }); + + test("the adjacent-day fallback still moves a day on a DST boundary", () => { + // Fall-back day: `from` + 24h is still the same local date, so a fixed-millisecond shift would + // hand back a range that starts and ends on 1 Nov. + const result = applyRangeClick( + { from: startOfLocalDay(day(2026, 10, 1)), to: endOfLocalDay(day(2026, 10, 5)) }, + "from", + day(2026, 11, 1) + ); + + expect(result.range.from?.getDate()).toBe(1); + expect(result.range.to?.getDate()).toBe(2); + expect(result.range.from!.getTime()).toBeLessThan(result.range.to!.getTime()); + }); + + test("a 'to' click with no 'from' yet leaves the range incomplete", () => { + const result = applyRangeClick({ from: undefined }, "to", day(2026, 3, 10)); + + expect(result.range.from).toBeUndefined(); + expect(result.isComplete).toBe(false); + }); +}); + +describe("applyRangeHover", () => { + test("previews the bound under the pointer while keeping the opposite one", () => { + const existingTo = endOfLocalDay(day(2026, 3, 31)); + const preview = applyRangeHover( + { from: startOfLocalDay(day(2026, 3, 10)), to: existingTo }, + "from", + day(2026, 3, 5) + ); + + expect(preview?.from?.getDate()).toBe(5); + expect(preview?.to?.getTime()).toBe(existingTo.getTime()); + }); + + test("returns null rather than an inverted preview", () => { + const range: TDateRangeValue = { + from: startOfLocalDay(day(2026, 3, 10)), + to: endOfLocalDay(day(2026, 3, 20)), + }; + + expect(applyRangeHover(range, "from", day(2026, 3, 25))).toBeNull(); + expect(applyRangeHover(range, "to", day(2026, 3, 5))).toBeNull(); + }); + + test("previews freely while the opposite bound is still unset", () => { + expect(applyRangeHover({ from: undefined }, "to", day(2026, 3, 5))?.to?.getDate()).toBe(5); + expect(applyRangeHover({ from: undefined }, "from", day(2026, 3, 5))?.from?.getDate()).toBe(5); + }); +}); diff --git a/apps/web/modules/ui/components/date-picker/lib/range.ts b/apps/web/modules/ui/components/date-picker/lib/range.ts new file mode 100644 index 000000000000..f6d1c53661e8 --- /dev/null +++ b/apps/web/modules/ui/components/date-picker/lib/range.ts @@ -0,0 +1,102 @@ +/** + * Selection transitions for a two-click date range. + * + * `react-day-picker`'s own `mode="range"` handles the two clicks but has no notion of a bound being + * "the one you are about to pick", and it previews nothing while the pointer moves. Both matter here: + * the range feeds response/chart filters, so the two bounds are not interchangeable calendar days but a + * half-open interval that has to cover whole local days — `from` at 00:00:00.000 and `to` at + * 23:59:59.999 — or a response recorded at 4pm on the last day of the range drops out of it. + * + * The transitions live here rather than in the component so they can be tested without a browser. + */ + +export interface TDateRangeValue { + from: Date | undefined; + to?: Date; +} + +/** Which bound the next click sets. */ +export type TDateRangeBound = "from" | "to"; + +export const startOfLocalDay = (date: Date): Date => { + const next = new Date(date); + next.setHours(0, 0, 0, 0); + return next; +}; + +export const endOfLocalDay = (date: Date): Date => { + const next = new Date(date); + next.setHours(23, 59, 59, 999); + return next; +}; + +/** + * Shifts by whole calendar days. + * + * Not `± 24h`: a local day is 23 or 25 hours long at a daylight-saving transition, so adding + * 86_400_000ms to local midnight on a fall-back day lands at 23:00 the *same* date — which would make + * the adjacent-day fallback below return a same-day range instead of moving the bound. + */ +export const addLocalDays = (date: Date, days: number): Date => { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +}; + +export interface TRangeClickResult { + range: TDateRangeValue; + /** Which bound the *following* click should set. */ + nextBound: TDateRangeBound; + /** True once both bounds are set and the caller can emit / close the calendar. */ + isComplete: boolean; +} + +/** + * Applies a day click to the range. + * + * Picking a bound that would invert the range moves the *other* bound to the adjacent day instead of + * rejecting the click or silently swapping the two. Swapping reads as the calendar ignoring where you + * clicked; a one-day range at the day you actually clicked keeps that click meaningful, and the next + * click widens it. + */ +export const applyRangeClick = ( + range: TDateRangeValue, + bound: TDateRangeBound, + date: Date +): TRangeClickResult => { + if (bound === "from") { + const from = startOfLocalDay(date); + + // `to` unset (nothing to invert) or still after the new `from`: keep it. + const isInverted = range.to !== undefined && from > range.to; + const to = isInverted ? endOfLocalDay(addLocalDays(from, 1)) : range.to; + + return { range: { from, to }, nextBound: "to", isComplete: false }; + } + + const to = endOfLocalDay(date); + const isInverted = range.from !== undefined && to < range.from; + const from = isInverted ? startOfLocalDay(addLocalDays(to, -1)) : range.from; + + return { range: { from, to }, nextBound: "from", isComplete: from !== undefined }; +}; + +/** + * The range to paint while the pointer is over `date`, or `null` when hovering there would invert the + * range — in which case the committed range keeps being shown rather than a misleading preview. + */ +export const applyRangeHover = ( + range: TDateRangeValue, + bound: TDateRangeBound, + date: Date +): TDateRangeValue | null => { + if (bound === "from") { + const from = startOfLocalDay(date); + if (range.to !== undefined && from > range.to) return null; + return { from, to: range.to }; + } + + const to = endOfLocalDay(date); + if (range.from !== undefined && to < range.from) return null; + return { from: range.from, to }; +}; diff --git a/apps/web/modules/ui/components/date-picker/styles.css b/apps/web/modules/ui/components/date-picker/styles.css deleted file mode 100644 index 8f4e62888704..000000000000 --- a/apps/web/modules/ui/components/date-picker/styles.css +++ /dev/null @@ -1,55 +0,0 @@ -.react-calendar__navigation button { - margin: 1% !important; - border-radius: 6%; -} - -.react-calendar__navigation button:disabled { - background-color: var(--slate-100) !important; - color: var(--slate-300) !important; - cursor: not-allowed !important; - pointer-events: none !important; -} - -.react-calendar__navigation button:enabled:hover, -.react-calendar__navigation button:enabled:focus { - background-color: var(--slate-200) !important; -} - -.react-calendar__month-view__weekdays { - text-decoration-style: dotted !important; - text-decoration-line: underline !important; -} - -.react-calendar__month-view__days__day--weekend { - color: var(--fb-brand) !important; -} - -.react-calendar__tile:disabled { - background-color: var(--slate-100) !important; - color: var(--slate-400) !important; - cursor: not-allowed !important; - pointer-events: none !important; - opacity: 0.5 !important; -} - -.react-calendar__tile:enabled:hover, -.react-calendar__tile:enabled:focus { - background-color: var(--slate-200) !important; - color: var(--slate-900) !important; - text-decoration: underline !important; -} - -.react-calendar__tile--now { - background: none !important; -} - -.react-calendar__tile .react-calendar__year-view__months__month { - margin: 20px !important; - background-color: var(--slate-100) !important; - color: var(--slate-900) !important; -} - -.react-calendar__tile--hasActive { - background-color: var(--fb-brand-color) !important; - border-radius: 6% !important; -} diff --git a/apps/web/package.json b/apps/web/package.json index 595d7357c7ec..675090acbdaf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -144,7 +144,6 @@ "qr-code-styling": "1.9.2", "qrcode": "1.5.4", "react": "catalog:", - "react-calendar": "6.0.1", "react-colorful": "5.6.2", "react-confetti": "6.4.0", "react-day-picker": "catalog:", diff --git a/apps/web/playwright/survey-scheduling.spec.ts b/apps/web/playwright/survey-scheduling.spec.ts index a8fcc7074d55..042a6d8c3cd2 100644 --- a/apps/web/playwright/survey-scheduling.spec.ts +++ b/apps/web/playwright/survey-scheduling.spec.ts @@ -69,24 +69,23 @@ const pickDateForToggle = async (page: Page, toggleTitle: string, dayOffset: num await datePickerTrigger.click(); const calendarPopover = page.locator("[data-radix-popper-content-wrapper]").last(); - const calendar = calendarPopover.locator(".react-calendar"); + const calendar = calendarPopover.locator(".rdp-root"); const targetMonthLabel = formatVisibleMonth(targetDate); for (let attempt = 0; attempt < 12; attempt++) { - const visibleMonthLabel = ( - await calendar.locator(".react-calendar__navigation__label").textContent() - )?.trim(); + const visibleMonthLabel = (await calendar.locator(".rdp-caption_label").textContent())?.trim(); if (visibleMonthLabel?.includes(targetMonthLabel)) { break; } - await calendar.locator(".react-calendar__navigation__next-button").click(); + await calendar.locator(".rdp-button_next").click(); } + // `:not(.rdp-outside)` matters: the grid pads with the neighbouring months' days, so a bare day-number + // match can hit the same number in the wrong month. await calendar - .locator(".react-calendar__month-view__days") - .locator("button:not([disabled])") + .locator(".rdp-day:not(.rdp-outside) .rdp-day_button:not([disabled])") .filter({ hasText: new RegExp(`^${targetDate.getDate().toString()}$`) }) .click(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9506194d42b2..2737977e7345 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -704,9 +704,6 @@ importers: react: specifier: 'catalog:' version: 19.2.6 - react-calendar: - specifier: 6.0.1 - version: 6.0.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-colorful: specifier: 5.6.2 version: 5.6.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -6924,9 +6921,6 @@ packages: '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} - '@wojtekmaj/date-utils@2.0.2': - resolution: {integrity: sha512-Do66mSlSNifFFuo3l9gNKfRMSFi26CRuQMsDJuuKO/ekrDWuTTtE4ZQxoFCUOG+NgxnpSeBq/k5TY8ZseEzLpA==} - '@xmldom/is-dom-node@1.0.1': resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==} engines: {node: '>= 16'} @@ -8681,9 +8675,6 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} - get-user-locale@3.0.0: - resolution: {integrity: sha512-iJfHSmdYV39UUBw7Jq6GJzeJxUr4U+S03qdhVuDsR9gCEnfbqLy9gYDJFBJQL1riqolFUKQvx36mEkp2iGgJ3g==} - giget@3.2.0: resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} hasBin: true @@ -9692,10 +9683,6 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} - memoize@10.2.0: - resolution: {integrity: sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==} - engines: {node: '>=18'} - memory-pager@1.5.0: resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} @@ -10713,16 +10700,6 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-calendar@6.0.1: - resolution: {integrity: sha512-b8E61W7qk/He9XEbtbQBjnALPuGmxeglsotgZyAShqN1vHMzXWjl4g7WI5tRF93RE4Wbo0c0BKN3vTQhrBojpg==} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - react-colorful@5.6.2: resolution: {integrity: sha512-7Vankf05ygS7v4T1gJPxqNIJZcsZ46K71J3fF995cfYOMFskAkFYUXna+90bwK/dr/1zVqrJQorNuc9OTV/qXA==} peerDependencies: @@ -12243,9 +12220,6 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - warning@4.0.3: - resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} - watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -19395,8 +19369,6 @@ snapshots: '@webcontainer/env@1.1.1': {} - '@wojtekmaj/date-utils@2.0.2': {} - '@xmldom/is-dom-node@1.0.1': {} '@xmldom/xmldom@0.9.10': {} @@ -21366,10 +21338,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-user-locale@3.0.0: - dependencies: - memoize: 10.2.0 - giget@3.2.0: {} github-from-package@0.0.0: {} @@ -22422,10 +22390,6 @@ snapshots: media-typer@1.1.0: optional: true - memoize@10.2.0: - dependencies: - mimic-function: 5.0.1 - memory-pager@1.5.0: {} merge-descriptors@2.0.0: @@ -23480,17 +23444,6 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-calendar@6.0.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@wojtekmaj/date-utils': 2.0.2 - clsx: 2.1.1 - get-user-locale: 3.0.0 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - warning: 4.0.3 - optionalDependencies: - '@types/react': 19.2.14 - react-colorful@5.6.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: react: 19.2.6 @@ -25293,10 +25246,6 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - warning@4.0.3: - dependencies: - loose-envify: 1.4.0 - watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1