Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<DatePicker
date={date}
updateSurveyDate={handleDateUpdate}
minDate={tomorrow}
onClearDate={() => updateSurveyDate(null)}
/>
);
return tomorrow;
};

export const PersonalLinksTab = ({
Expand All @@ -77,7 +58,7 @@ export const PersonalLinksTab = ({
isFormbricksCloud,
enterpriseLicenseRequestFormUrl,
}: PersonalLinksTabProps) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { workspace } = useWorkspace();

const form = useForm<PersonalLinksFormData>({
Expand Down Expand Up @@ -233,7 +214,13 @@ export const PersonalLinksTab = ({
<FormItem>
<FormLabel>{t("workspace.surveys.share.personal_links.expiry_date_optional")}</FormLabel>
<FormControl>
<RestrictedDatePicker date={field.value} updateSurveyDate={field.onChange} />
<DatePicker
value={field.value ?? null}
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
minDate={getTomorrow()}
onChange={field.onChange}
onClear={() => field.onChange(null)}
/>
</FormControl>
<FormDescription>
{t("workspace.surveys.share.personal_links.expiry_date_description")}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -70,27 +65,41 @@ 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);
return matched ? matched.getLabel(t) : getFilterDropDownLabels(t).CUSTOM_RANGE;
};

export const CustomFilter = ({ survey }: Readonly<CustomFilterProps>) => {
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>(DateSelected.FROM);
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
const [isDownloading, setIsDownloading] = useState<boolean>(false);

const firstMountRef = useRef(true);
Expand Down Expand Up @@ -130,66 +139,8 @@ export const CustomFilter = ({ survey }: Readonly<CustomFilterProps>) => {
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") => {
Expand Down Expand Up @@ -233,9 +184,7 @@ export const CustomFilter = ({ survey }: Readonly<CustomFilterProps>) => {
<DropdownMenuTrigger asChild>
<PopoverTriggerButton isOpen={isFilterDropDownOpen}>
{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}
</PopoverTriggerButton>
</DropdownMenuTrigger>
Expand All @@ -261,7 +210,6 @@ export const CustomFilter = ({ survey }: Readonly<CustomFilterProps>) => {
onClick={() => {
setIsDatePickerOpen(true);
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
setSelectingDate(DateSelected.FROM);
}}>
<p className="text-sm text-slate-700 hover:ring-0">{getFilterDropDownLabels(t).CUSTOM_RANGE}</p>
</DropdownMenuItem>
Expand Down Expand Up @@ -315,18 +263,11 @@ export const CustomFilter = ({ survey }: Readonly<CustomFilterProps>) => {
</div>
{isDatePickerOpen && (
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
<Calendar
autoFocus
mode="range"
defaultMonth={dateRange?.from}
selected={hoveredRange || dateRange}
numberOfMonths={2}
onDayClick={(date) => handleDateChange(date)}
onDayMouseEnter={handleDateHoveredChange}
onDayMouseLeave={() => setHoveredRange(null)}
classNames={{
day_today: "hover:bg-slate-200 bg-white",
}}
<DateRangeCalendar
value={dateRange}
locale={locale}
onChange={setDateRange}
onComplete={() => setIsDatePickerOpen(false)}
/>
</div>
)}
Expand Down
6 changes: 4 additions & 2 deletions apps/web/i18n.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions apps/web/lib/utils/datetime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import {
formatDateForDisplay,
formatDateTimeForDisplay,
formatDateWithOrdinal,
formatLocalDay,
getDateFnsLocale,
getFormattedDateTimeString,
isValidDateString,
parseLocalDay,
} from "./datetime";

describe("datetime utils", () => {
Expand Down Expand Up @@ -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");
});
});
Loading
Loading