From 707eae68c17200e977a3b5eaa005707699f414cd Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Tue, 28 Jun 2022 18:36:23 +0100 Subject: [PATCH] Switch from date-fns to day.js (#213) --- package.json | 5 +- src/components/discussion/discussion.tsx | 12 +- .../poll-options-form/dayjs-localizer.ts | 357 ++++++++++++++++++ .../month-calendar/month-calendar.tsx | 34 +- .../month-calendar/time-picker.tsx | 20 +- .../forms/poll-options-form/utils.ts | 6 +- .../forms/poll-options-form/week-calendar.tsx | 62 ++- src/components/headless-date-picker.tsx | 47 +-- src/components/home/poll-demo.tsx | 8 +- src/components/poll-context.tsx | 8 +- .../poll/manage-poll/use-csv-exporter.ts | 7 +- src/components/poll/poll-subheader.tsx | 8 +- .../preferences/preferences-provider.tsx | 32 +- src/components/profile.tsx | 4 +- src/pages/api/house-keeping.ts | 8 +- src/server/routers/polls/demo.ts | 7 +- src/utils/date-time-utils.ts | 120 +++--- tests/house-keeping.spec.ts | 12 +- yarn.lock | 21 +- 19 files changed, 549 insertions(+), 229 deletions(-) create mode 100644 src/components/forms/poll-options-form/dayjs-localizer.ts diff --git a/package.json b/package.json index 0419a999a..6988c6d50 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,7 @@ "@trpc/server": "^9.23.2", "axios": "^0.24.0", "clsx": "^1.1.1", - "date-fns": "^2.28.0", - "date-fns-tz": "^1.2.2", + "dayjs": "^1.11.3", "eta": "^1.12.3", "framer-motion": "^6.3.11", "iron-session": "^6.1.3", @@ -52,7 +51,7 @@ "react-query": "^3.34.12", "react-use": "^17.3.2", "smoothscroll-polyfill": "^0.4.4", - "spacetime": "^7.1.2", + "spacetime": "^7.1.4", "superjson": "^1.9.1", "timezone-soft": "^1.3.1", "typescript": "^4.5.2", diff --git a/src/components/discussion/discussion.tsx b/src/components/discussion/discussion.tsx index 1fcaaed16..c8885c21d 100644 --- a/src/components/discussion/discussion.tsx +++ b/src/components/discussion/discussion.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { formatRelative } from "date-fns"; +import dayjs from "dayjs"; import { AnimatePresence, motion } from "framer-motion"; import { usePlausible } from "next-plausible"; import * as React from "react"; @@ -16,7 +16,6 @@ import NameInput from "../name-input"; import TruncatedLinkify from "../poll/truncated-linkify"; import UserAvatar from "../poll/user-avatar"; import { usePoll } from "../poll-context"; -import { usePreferences } from "../preferences/use-preferences"; import { isUnclaimed, useSession } from "../session"; interface CommentForm { @@ -25,7 +24,6 @@ interface CommentForm { } const Discussion: React.VoidFunctionComponent = () => { - const { locale } = usePreferences(); const queryClient = trpc.useContext(); const { poll } = usePoll(); @@ -122,13 +120,7 @@ const Discussion: React.VoidFunctionComponent = () => {
- {formatRelative( - new Date(comment.createdAt), - Date.now(), - { - locale, - }, - )} + {dayjs(new Date(comment.createdAt)).fromNow()}
+ local.format(start, "MMMM DD", culture) + + " – " + + local.format(end, local.eq(start, end, "month") ? "DD" : "MMMM DD", culture); + +const dateRangeFormat = ({ start, end }, culture, local) => + local.format(start, "L", culture) + " – " + local.format(end, "L", culture); + +const timeRangeFormat = ({ start, end }, culture, local) => + local.format(start, "LT", culture) + " – " + local.format(end, "LT", culture); + +const timeRangeStartFormat = ({ start }, culture, local) => + local.format(start, "LT", culture) + " – "; + +const timeRangeEndFormat = ({ end }, culture, local) => + " – " + local.format(end, "LT", culture); + +export const formats = { + dateFormat: "DD", + dayFormat: "DD ddd", + weekdayFormat: "ddd", + + selectRangeFormat: timeRangeFormat, + eventTimeRangeFormat: timeRangeFormat, + eventTimeRangeStartFormat: timeRangeStartFormat, + eventTimeRangeEndFormat: timeRangeEndFormat, + + timeGutterFormat: "LT", + + monthHeaderFormat: "MMMM YYYY", + dayHeaderFormat: "dddd MMM DD", + dayRangeHeaderFormat: weekRangeFormat, + agendaHeaderFormat: dateRangeFormat, + + agendaDateFormat: "ddd MMM DD", + agendaTimeFormat: "LT", + agendaTimeRangeFormat: timeRangeFormat, +}; + +function fixUnit(unit) { + let datePart = unit ? unit.toLowerCase() : unit; + if (datePart === "FullYear") { + datePart = "year"; + } else if (!datePart) { + datePart = undefined; + } + return datePart; +} + +export default function (dayjs) { + const locale = (m, c) => (c ? m.locale(c) : m); + + /*** BEGIN localized date arithmetic methods with dayjs ***/ + function defineComparators(a, b, unit) { + const datePart = fixUnit(unit); + const dtA = datePart ? dayjs(a).startOf(datePart) : dayjs(a); + const dtB = datePart ? dayjs(b).startOf(datePart) : dayjs(b); + return [dtA, dtB, datePart]; + } + + function startOf(date = null, unit) { + const datePart = fixUnit(unit); + if (datePart) { + return dayjs(date).startOf(datePart).toDate(); + } + return dayjs(date).toDate(); + } + + function endOf(date = null, unit) { + const datePart = fixUnit(unit); + if (datePart) { + return dayjs(date).endOf(datePart).toDate(); + } + return dayjs(date).toDate(); + } + + // dayjs comparison operations *always* convert both sides to dayjs objects + // prior to running the comparisons + function eq(a, b, unit) { + const [dtA, dtB, datePart] = defineComparators(a, b, unit); + return dtA.isSame(dtB, datePart); + } + + function neq(a, b, unit) { + return !eq(a, b, unit); + } + + function gt(a, b, unit) { + const [dtA, dtB, datePart] = defineComparators(a, b, unit); + return dtA.isAfter(dtB, datePart); + } + + function lt(a, b, unit) { + const [dtA, dtB, datePart] = defineComparators(a, b, unit); + return dtA.isBefore(dtB, datePart); + } + + function gte(a, b, unit) { + const [dtA, dtB, datePart] = defineComparators(a, b, unit); + return dtA.isSameOrBefore(dtB, datePart); + } + + function lte(a, b, unit) { + const [dtA, dtB, datePart] = defineComparators(a, b, unit); + return dtA.isSameOrBefore(dtB, datePart); + } + + function inRange(day, min, max, unit = "day") { + const datePart = fixUnit(unit); + const mDay = dayjs(day); + const mMin = dayjs(min); + const mMax = dayjs(max); + return mDay.isBetween(mMin, mMax, datePart, "[]"); + } + + function min(dateA, dateB) { + const dtA = dayjs(dateA); + const dtB = dayjs(dateB); + const minDt = dayjs.min(dtA, dtB); + return minDt.toDate(); + } + + function max(dateA, dateB) { + const dtA = dayjs(dateA); + const dtB = dayjs(dateB); + const maxDt = dayjs.max(dtA, dtB); + return maxDt.toDate(); + } + + function merge(date, time) { + if (!date && !time) return null; + + const tm = dayjs(time).format("HH:mm:ss"); + const dt = dayjs(date).startOf("day").format("MM/DD/YYYY"); + // We do it this way to avoid issues when timezone switching + return dayjs(`${dt} ${tm}`, "MM/DD/YYYY HH:mm:ss").toDate(); + } + + function add(date, adder, unit) { + const datePart = fixUnit(unit); + return dayjs(date).add(adder, datePart).toDate(); + } + + function range(start, end, unit = "day") { + const datePart = fixUnit(unit); + // because the add method will put these in tz, we have to start that way + let current = dayjs(start).toDate(); + const days = []; + + while (lte(current, end)) { + days.push(current); + current = add(current, 1, datePart); + } + + return days; + } + + function ceil(date, unit) { + const datePart = fixUnit(unit); + const floor = startOf(date, datePart); + + return eq(floor, date) ? floor : add(floor, 1, datePart); + } + + function diff(a, b, unit = "day") { + const datePart = fixUnit(unit); + // don't use 'defineComparators' here, as we don't want to mutate the values + const dtA = dayjs(a); + const dtB = dayjs(b); + return dtB.diff(dtA, datePart); + } + + function minutes(date) { + const dt = dayjs(date); + return dt.minutes(); + } + + function firstOfWeek() { + const data = dayjs.localeData(); + return data ? data.firstDayOfWeek() : 0; + } + + function firstVisibleDay(date) { + return dayjs(date).startOf("month").startOf("week").toDate(); + } + + function lastVisibleDay(date) { + return dayjs(date).endOf("month").endOf("week").toDate(); + } + + function visibleDays(date) { + let current = firstVisibleDay(date); + const last = lastVisibleDay(date); + const days = []; + + while (lte(current, last)) { + days.push(current); + current = add(current, 1, "d"); + } + + return days; + } + /*** END localized date arithmetic methods with dayjs ***/ + + /** + * Moved from TimeSlots.js, this method overrides the method of the same name + * in the localizer.js, using dayjs to construct the js Date + * @param {Date} dt - date to start with + * @param {Number} minutesFromMidnight + * @param {Number} offset + * @returns {Date} + */ + function getSlotDate(dt, minutesFromMidnight, offset) { + return dayjs(dt) + .startOf("day") + .minute(minutesFromMidnight + offset) + .toDate(); + } + + // dayjs will automatically handle DST differences in it's calculations + function getTotalMin(start, end) { + return diff(start, end, "minutes"); + } + + function getMinutesFromMidnight(start) { + const dayStart = dayjs(start).startOf("day"); + const day = dayjs(start); + return day.diff(dayStart, "minutes"); + } + + // These two are used by DateSlotMetrics + function continuesPrior(start, first) { + const mStart = dayjs(start); + const mFirst = dayjs(first); + return mStart.isBefore(mFirst, "day"); + } + + function continuesAfter(start, end, last) { + const mEnd = dayjs(end); + const mLast = dayjs(last); + return mEnd.isSameOrAfter(mLast, "minutes"); + } + + // These two are used by eventLevels + function sortEvents({ + evtA: { start: aStart, end: aEnd, allDay: aAllDay }, + evtB: { start: bStart, end: bEnd, allDay: bAllDay }, + }) { + const startSort = +startOf(aStart, "day") - +startOf(bStart, "day"); + + const durA = diff(aStart, ceil(aEnd, "day"), "day"); + + const durB = diff(bStart, ceil(bEnd, "day"), "day"); + + return ( + startSort || // sort by start Day first + Math.max(durB, 1) - Math.max(durA, 1) || // events spanning multiple days go first + !!bAllDay - !!aAllDay || // then allDay single day events + +aStart - +bStart || // then sort by start time *don't need dayjs conversion here + +aEnd - +bEnd // then sort by end time *don't need dayjs conversion here either + ); + } + + function inEventRange({ + event: { start, end }, + range: { start: rangeStart, end: rangeEnd }, + }) { + const startOfDay = dayjs(start).startOf("day"); + const eEnd = dayjs(end); + const rStart = dayjs(rangeStart); + const rEnd = dayjs(rangeEnd); + + const startsBeforeEnd = startOfDay.isSameOrBefore(rEnd, "day"); + // when the event is zero duration we need to handle a bit differently + const sameMin = !startOfDay.isSame(eEnd, "minutes"); + const endsAfterStart = sameMin + ? eEnd.isAfter(rStart, "minutes") + : eEnd.isSameOrAfter(rStart, "minutes"); + + return startsBeforeEnd && endsAfterStart; + } + + // dayjs treats 'day' and 'date' equality very different + // dayjs(date1).isSame(date2, 'day') would test that they were both the same day of the week + // dayjs(date1).isSame(date2, 'date') would test that they were both the same date of the month of the year + function isSameDate(date1, date2) { + const dt = dayjs(date1); + const dt2 = dayjs(date2); + return dt.isSame(dt2, "date"); + } + + /** + * This method, called once in the localizer constructor, is used by eventLevels + * 'eventSegments()' to assist in determining the 'span' of the event in the display, + * specifically when using a timezone that is greater than the browser native timezone. + * @returns number + */ + function browserTZOffset() { + /** + * Date.prototype.getTimezoneOffset horrifically flips the positive/negative from + * what you see in it's string, so we have to jump through some hoops to get a value + * we can actually compare. + */ + const dt = new Date(); + const neg = /-/.test(dt.toString()) ? "-" : ""; + const dtOffset = dt.getTimezoneOffset(); + const comparator = Number(`${neg}${Math.abs(dtOffset)}`); + // dayjs correctly provides positive/negative offset, as expected + const mtOffset = dayjs().utcOffset(); + return mtOffset > comparator ? 1 : 0; + } + + return new DateLocalizer({ + formats, + + firstOfWeek, + firstVisibleDay, + lastVisibleDay, + visibleDays, + + format(value, format, culture) { + return locale(dayjs(value), culture).format(format); + }, + + lt, + lte, + gt, + gte, + eq, + neq, + merge, + inRange, + startOf, + endOf, + range, + add, + diff, + ceil, + min, + max, + minutes, + + getSlotDate, + getTotalMin, + getMinutesFromMidnight, + continuesPrior, + continuesAfter, + sortEvents, + inEventRange, + isSameDate, + browserTZOffset, + }); +} diff --git a/src/components/forms/poll-options-form/month-calendar/month-calendar.tsx b/src/components/forms/poll-options-form/month-calendar/month-calendar.tsx index 82e90a91f..9077a49ad 100644 --- a/src/components/forms/poll-options-form/month-calendar/month-calendar.tsx +++ b/src/components/forms/poll-options-form/month-calendar/month-calendar.tsx @@ -1,7 +1,5 @@ import clsx from "clsx"; -import differenceInMinutes from "date-fns/differenceInMinutes"; -import { addMinutes, setHours } from "date-fns/esm"; -import isSameDay from "date-fns/isSameDay"; +import dayjs from "dayjs"; import { usePlausible } from "next-plausible"; import * as React from "react"; @@ -126,12 +124,14 @@ const MonthCalendar: React.VoidFunctionComponent = ({ onClick={() => { if ( datepicker.selection.some((selectedDate) => - isSameDay(selectedDate, day.date), + dayjs(selectedDate).isSame(day.date, "day"), ) ) { onChange(removeAllOptionsForDay(options, day.date)); } else { - const selectedDate = setHours(day.date, 12); + const selectedDate = dayjs(day.date) + .set("hour", 12) + .toDate(); const newOption: DateTimeOption = !isTimedEvent ? { type: "date", @@ -141,7 +141,9 @@ const MonthCalendar: React.VoidFunctionComponent = ({ type: "timeSlot", start: formatDateWithoutTz(selectedDate), end: formatDateWithoutTz( - addMinutes(selectedDate, duration), + dayjs(selectedDate) + .add(duration, "minutes") + .toDate(), ), }; @@ -208,7 +210,9 @@ const MonthCalendar: React.VoidFunctionComponent = ({ ); } const startDate = new Date(`${option.date}T12:00:00`); - const endDate = addMinutes(startDate, duration); + const endDate = dayjs(startDate) + .add(duration, "minutes") + .toDate(); return { type: "timeSlot", start: formatDateWithoutTz(startDate), @@ -260,7 +264,9 @@ const MonthCalendar: React.VoidFunctionComponent = ({ { - const newEnd = addMinutes(newStart, duration); + const newEnd = dayjs(newStart) + .add(duration, "minutes") + .toDate(); // replace enter with updated start time onChange([ ...options.slice(0, index), @@ -273,13 +279,15 @@ const MonthCalendar: React.VoidFunctionComponent = ({ ]); onNavigate(newStart); onChangeDuration( - differenceInMinutes(newEnd, newStart), + dayjs(newEnd).diff(newStart, "minutes"), ); }} /> { onChange([ ...options.slice(0, index), @@ -291,7 +299,7 @@ const MonthCalendar: React.VoidFunctionComponent = ({ ]); onNavigate(newEnd); onChangeDuration( - differenceInMinutes(newEnd, startDate), + dayjs(newEnd).diff(startDate, "minutes"), ); }} /> @@ -322,7 +330,9 @@ const MonthCalendar: React.VoidFunctionComponent = ({ type: "timeSlot", start: startTime, end: formatDateWithoutTz( - addMinutes(new Date(startTime), duration), + dayjs(new Date(startTime)) + .add(duration, "minutes") + .toDate(), ), }, ]); diff --git a/src/components/forms/poll-options-form/month-calendar/time-picker.tsx b/src/components/forms/poll-options-form/month-calendar/time-picker.tsx index 11f6fcf87..5a48a38ef 100644 --- a/src/components/forms/poll-options-form/month-calendar/time-picker.tsx +++ b/src/components/forms/poll-options-form/month-calendar/time-picker.tsx @@ -7,10 +7,9 @@ import { } from "@floating-ui/react-dom-interactions"; import { Listbox } from "@headlessui/react"; import clsx from "clsx"; -import { addMinutes, format, isSameDay, setHours, setMinutes } from "date-fns"; +import dayjs from "dayjs"; import * as React from "react"; -import { usePreferences } from "@/components/preferences/use-preferences"; import { stopPropagation } from "@/utils/stop-propagation"; import ChevronDown from "../../../icons/chevron-down.svg"; @@ -27,9 +26,8 @@ const TimePicker: React.VoidFunctionComponent = ({ value, onChange, className, - startFrom = setMinutes(setHours(value, 0), 0), + startFrom, }) => { - const { locale } = usePreferences(); const { reference, floating, x, y, strategy, refs } = useFloating({ strategy: "fixed", middleware: [ @@ -47,10 +45,14 @@ const TimePicker: React.VoidFunctionComponent = ({ ], }); + const startFromDate = startFrom + ? dayjs(startFrom) + : dayjs(value).startOf("day"); + const options: React.ReactNode[] = []; for (let i = 0; i < 96; i++) { - const optionValue = addMinutes(startFrom, i * 15); - if (!isSameDay(value, optionValue)) { + const optionValue = startFromDate.add(i * 15, "minutes"); + if (!optionValue.isSame(value, "day")) { // we only support event that start and end on the same day for now // because react-big-calendar does not support events that span days break; @@ -61,7 +63,7 @@ const TimePicker: React.VoidFunctionComponent = ({ className={styleMenuItem} value={optionValue.toISOString()} > - {format(optionValue, "p", { locale })} + {optionValue.format("LT")} , ); } @@ -77,9 +79,7 @@ const TimePicker: React.VoidFunctionComponent = ({ <>
- - {format(value, "p", { locale })} - + {dayjs(value).format("LT")} diff --git a/src/components/forms/poll-options-form/utils.ts b/src/components/forms/poll-options-form/utils.ts index 741c4d695..dbfa291aa 100644 --- a/src/components/forms/poll-options-form/utils.ts +++ b/src/components/forms/poll-options-form/utils.ts @@ -1,9 +1,9 @@ -import { format } from "date-fns"; +import dayjs from "dayjs"; export const formatDateWithoutTz = (date: Date): string => { - return format(date, "yyyy-MM-dd'T'HH:mm:ss"); + return dayjs(date).format("YYYY-MM-DDTHH:mm:ss"); }; export const formatDateWithoutTime = (date: Date): string => { - return format(date, "yyyy-MM-dd"); + return dayjs(date).format("YYYY-MM-DD"); }; diff --git a/src/components/forms/poll-options-form/week-calendar.tsx b/src/components/forms/poll-options-form/week-calendar.tsx index 641e6b0a4..a6f36ea29 100644 --- a/src/components/forms/poll-options-form/week-calendar.tsx +++ b/src/components/forms/poll-options-form/week-calendar.tsx @@ -1,22 +1,18 @@ import clsx from "clsx"; -import { - addMinutes, - differenceInMinutes, - format, - getDay, - parse, - startOfWeek, -} from "date-fns"; +import dayjs from "dayjs"; import React from "react"; -import { Calendar, dateFnsLocalizer } from "react-big-calendar"; +import { Calendar } from "react-big-calendar"; import { useMount } from "react-use"; -import { usePreferences } from "@/components/preferences/use-preferences"; - +import { getDuration } from "../../../utils/date-time-utils"; +import { usePreferences } from "../../preferences/use-preferences"; import DateNavigationToolbar from "./date-navigation-toolbar"; +import dayjsLocalizer from "./dayjs-localizer"; import { DateTimeOption, DateTimePickerProps } from "./types"; import { formatDateWithoutTime, formatDateWithoutTz } from "./utils"; +const localizer = dayjsLocalizer(dayjs); + const WeekCalendar: React.VoidFunctionComponent = ({ title, options, @@ -28,30 +24,12 @@ const WeekCalendar: React.VoidFunctionComponent = ({ }) => { const [scrollToTime, setScrollToTime] = React.useState(); + const { timeFormat } = usePreferences(); useMount(() => { // Bit of a hack to force rbc to scroll to the right time when we close/open a modal - setScrollToTime(addMinutes(date, -60)); + setScrollToTime(dayjs(date).add(-60, "minutes").toDate()); }); - const { weekStartsOn, timeFormat, locale } = usePreferences(); - - const localizer = React.useMemo( - () => - dateFnsLocalizer({ - format, - parse, - startOfWeek: (date: Date | number) => - startOfWeek(date, { - weekStartsOn: weekStartsOn === "monday" ? 1 : 0, - }), - getDay, - locales: { - default: locale, - }, - }), - [locale, weekStartsOn], - ); - return ( = ({ ); }, eventWrapper: (props) => { + const start = dayjs(props.event.start); + const end = dayjs(props.event.end); return (
= ({ width: `calc(${props.style?.width}%)`, }} > -
{format(props.event.start, "p", { locale })}
-
- {props.event.title} -
+
{start.format("LT")}
+
{getDuration(start, end)}
); }, @@ -158,15 +136,15 @@ const WeekCalendar: React.VoidFunctionComponent = ({ )} > - {format(date, "E")} + {dayjs(date).format("ddd")} - {format(date, "dd")} + {dayjs(date).format("DD")} ); }, }, timeSlotWrapper: ({ children }) => { - return
{children}
; + return
{children}
; }, }} step={15} @@ -182,12 +160,14 @@ const WeekCalendar: React.VoidFunctionComponent = ({ }; if (action === "select") { - const diff = differenceInMinutes(endDate, startDate); + const diff = dayjs(endDate).diff(startDate, "minutes"); if (diff < 60 * 24) { onChangeDuration(diff); } } else { - newEvent.end = formatDateWithoutTz(addMinutes(startDate, duration)); + newEvent.end = formatDateWithoutTz( + dayjs(startDate).add(duration, "minutes").toDate(), + ); } const alreadyExists = options.some( diff --git a/src/components/headless-date-picker.tsx b/src/components/headless-date-picker.tsx index 2ff2cf2a6..958c4bd19 100644 --- a/src/components/headless-date-picker.tsx +++ b/src/components/headless-date-picker.tsx @@ -1,13 +1,4 @@ -import { - addDays, - addMonths, - format, - getMonth, - isSameDay, - isWeekend, - startOfMonth, - startOfWeek, -} from "date-fns"; +import dayjs from "dayjs"; import React from "react"; interface DayProps { @@ -45,52 +36,50 @@ export const useHeadlessDatePicker = ( const [localSelection, setSelection] = React.useState([]); const selection = options?.selection ?? localSelection; const [localNavigationDate, setNavigationDate] = React.useState(today); - const navigationDate = options?.date ?? localNavigationDate; + const navigationDate = dayjs(options?.date ?? localNavigationDate); - const firstDayOfMonth = startOfMonth(navigationDate); - const firstDayOfFirstWeek = startOfWeek(firstDayOfMonth, { - weekStartsOn: options?.weekStartsOn === "monday" ? 1 : 0, - }); + const firstDayOfMonth = navigationDate.startOf("month"); + const firstDayOfFirstWeek = firstDayOfMonth.startOf("week"); - const currentMonth = getMonth(navigationDate); + const currentMonth = navigationDate.get("month"); const days: DayProps[] = []; const daysOfWeek: string[] = []; for (let i = 0; i < 7; i++) { - daysOfWeek.push(format(addDays(firstDayOfFirstWeek, i), "EE")); + daysOfWeek.push(firstDayOfFirstWeek.add(i, "days").format("dd")); } let reachedEnd = false; let i = 0; do { - const d = addDays(firstDayOfFirstWeek, i); + const d = firstDayOfFirstWeek.add(i, "days"); days.push({ - date: d, - day: format(d, "d"), - weekend: isWeekend(d), - outOfMonth: getMonth(d) !== currentMonth, - today: isSameDay(d, today), - selected: selection.some((selectedDate) => isSameDay(selectedDate, d)), + date: d.toDate(), + day: d.format("D"), + weekend: d.day() === 0 || d.day() === 6, + outOfMonth: d.month() !== currentMonth, + today: d.isSame(today, "day"), + selected: selection.some((selectedDate) => d.isSame(selectedDate, "day")), }); i++; reachedEnd = - i > 34 && i % 7 === 0 && addDays(d, 1).getMonth() !== currentMonth; + i > 34 && i % 7 === 0 && d.add(1, "day").month() !== currentMonth; } while (reachedEnd === false); return { - navigationDate, - label: format(navigationDate, "MMMM yyyy"), + navigationDate: navigationDate.toDate(), + label: navigationDate.format("MMMM YYYY"), next: () => { - const newDate = startOfMonth(addMonths(navigationDate, 1)); + const newDate = navigationDate.add(1, "month").startOf("month").toDate(); if (!options?.date) { setNavigationDate(newDate); } options?.onNavigationChange?.(newDate); }, prev: () => { - const newDate = startOfMonth(addMonths(navigationDate, -1)); + const newDate = navigationDate.add(-1, "month").startOf("month").toDate(); if (!options?.date) { setNavigationDate(newDate); } diff --git a/src/components/home/poll-demo.tsx b/src/components/home/poll-demo.tsx index 3f801ee49..48eb026c8 100644 --- a/src/components/home/poll-demo.tsx +++ b/src/components/home/poll-demo.tsx @@ -1,4 +1,4 @@ -import { format } from "date-fns"; +import dayjs from "dayjs"; import { useTranslation } from "next-i18next"; import * as React from "react"; @@ -66,11 +66,11 @@ const PollDemo: React.VoidFunctionComponent = () => {
- {format(d, "E")} + {dayjs(d).format("ddd")}
-
{format(d, "dd")}
+
{dayjs(d).format("DD")}
- {format(d, "MMM")} + {dayjs(d).format("MMM")}
diff --git a/src/components/poll-context.tsx b/src/components/poll-context.tsx index fb8dc0356..14e89455a 100644 --- a/src/components/poll-context.tsx +++ b/src/components/poll-context.tsx @@ -63,8 +63,6 @@ export const PollContextProvider: React.VoidFunctionComponent<{ const [targetTimeZone, setTargetTimeZone] = React.useState(getBrowserTimeZone); - const { locale } = usePreferences(); - const getScore = React.useCallback( (optionId: string) => { return (participants ?? []).reduce( @@ -88,6 +86,8 @@ export const PollContextProvider: React.VoidFunctionComponent<{ [participants], ); + const { timeFormat } = usePreferences(); + const contextValue = React.useMemo(() => { const highScore = poll.options.reduce((acc, curr) => { const score = getScore(curr.id).yes; @@ -99,7 +99,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{ poll.options, poll.timeZone, targetTimeZone, - locale, + timeFormat, ); const getParticipantById = (participantId: string) => { // TODO (Luke Vella) [2022-04-16]: Build an index instead @@ -164,10 +164,10 @@ export const PollContextProvider: React.VoidFunctionComponent<{ admin, getScore, isDeleted, - locale, participants, poll, targetTimeZone, + timeFormat, urlId, user, ]); diff --git a/src/components/poll/manage-poll/use-csv-exporter.ts b/src/components/poll/manage-poll/use-csv-exporter.ts index bb49b2e9b..78dca669c 100644 --- a/src/components/poll/manage-poll/use-csv-exporter.ts +++ b/src/components/poll/manage-poll/use-csv-exporter.ts @@ -1,4 +1,4 @@ -import { format } from "date-fns"; +import dayjs from "dayjs"; import { useTranslation } from "next-i18next"; import { usePoll } from "@/components/poll-context"; @@ -50,10 +50,7 @@ export const useCsvExporter = () => { link.setAttribute("href", encodedCsv); link.setAttribute( "download", - `${poll.title.replace(/\s/g, "_")}-${format( - Date.now(), - "yyyyMMddhhmm", - )}`, + `${poll.title.replace(/\s/g, "_")}-${dayjs().format("YYYYMMDDHHmm")}`, ); document.body.appendChild(link); link.click(); diff --git a/src/components/poll/poll-subheader.tsx b/src/components/poll/poll-subheader.tsx index 2586bff75..5e4692514 100644 --- a/src/components/poll/poll-subheader.tsx +++ b/src/components/poll/poll-subheader.tsx @@ -1,16 +1,14 @@ -import { formatRelative } from "date-fns"; +import dayjs from "dayjs"; import { Trans, useTranslation } from "next-i18next"; import * as React from "react"; import Badge from "../badge"; import { usePoll } from "../poll-context"; -import { usePreferences } from "../preferences/use-preferences"; import Tooltip from "../tooltip"; const PollSubheader: React.VoidFunctionComponent = () => { const { poll } = usePoll(); const { t } = useTranslation("app"); - const { locale } = usePreferences(); return (
@@ -45,9 +43,7 @@ const PollSubheader: React.VoidFunctionComponent = () => {
 •  - {formatRelative(poll.createdAt, new Date(), { - locale, - })} + {dayjs(poll.createdAt).fromNow()}
); diff --git a/src/components/preferences/preferences-provider.tsx b/src/components/preferences/preferences-provider.tsx index 55ab906bd..f50f04066 100644 --- a/src/components/preferences/preferences-provider.tsx +++ b/src/components/preferences/preferences-provider.tsx @@ -1,15 +1,32 @@ -import { Locale } from "date-fns"; -import enGB from "date-fns/locale/en-GB"; -import enUS from "date-fns/locale/en-US"; +import dayjs from "dayjs"; +import en from "dayjs/locale/en"; +import duration from "dayjs/plugin/duration"; +import isBetween from "dayjs/plugin/isBetween"; +import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; +import localeData from "dayjs/plugin/localeData"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import minMax from "dayjs/plugin/minMax"; +import relativeTime from "dayjs/plugin/relativeTime"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; import * as React from "react"; import { useLocalStorage } from "react-use"; type TimeFormat = "12h" | "24h"; type StartOfWeek = "monday" | "sunday"; +dayjs.extend(localizedFormat); +dayjs.extend(relativeTime); +dayjs.extend(localeData); +dayjs.extend(isSameOrBefore); +dayjs.extend(isBetween); +dayjs.extend(minMax); +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(duration); + export const PreferencesContext = React.createContext<{ - locale: Locale; weekStartsOn: StartOfWeek; timeFormat: TimeFormat; setWeekStartsOn: React.Dispatch< @@ -29,13 +46,18 @@ const PreferencesProvider: React.VoidFunctionComponent<{ const [timeFormat = "12h", setTimeFormat] = useLocalStorage("rallly-time-format"); + dayjs.locale({ + ...en, + weekStart: weekStartsOn === "monday" ? 1 : 0, + formats: { LT: timeFormat === "12h" ? "h:mm A" : "HH:mm" }, + }); + const contextValue = React.useMemo( () => ({ weekStartsOn, timeFormat, setWeekStartsOn, setTimeFormat, - locale: timeFormat === "12h" ? enUS : enGB, }), [setTimeFormat, setWeekStartsOn, timeFormat, weekStartsOn], ); diff --git a/src/components/profile.tsx b/src/components/profile.tsx index 92faa50f4..555e28060 100644 --- a/src/components/profile.tsx +++ b/src/components/profile.tsx @@ -1,4 +1,4 @@ -import { formatRelative } from "date-fns"; +import dayjs from "dayjs"; import Head from "next/head"; import Link from "next/link"; import { useTranslation } from "next-i18next"; @@ -83,7 +83,7 @@ export const Profile: React.VoidFunctionComponent = () => {
- {formatRelative(poll.createdAt, new Date())} + {dayjs(poll.createdAt).fromNow()}
diff --git a/src/pages/api/house-keeping.ts b/src/pages/api/house-keeping.ts index 00799c4a8..0cc2e00b4 100644 --- a/src/pages/api/house-keeping.ts +++ b/src/pages/api/house-keeping.ts @@ -1,5 +1,5 @@ import { Prisma } from "@prisma/client"; -import { addDays } from "date-fns"; +import dayjs from "dayjs"; import { NextApiRequest, NextApiResponse } from "next"; import { prisma } from "~/prisma/db"; @@ -29,7 +29,7 @@ export default async function handler( where: { deleted: false, touchedAt: { - lte: addDays(new Date(), -30), + lte: dayjs().add(-30, "days").toDate(), }, }, }); @@ -42,14 +42,14 @@ export default async function handler( { deleted: true, deletedAt: { - lte: addDays(new Date(), -7), + lte: dayjs().add(-7, "days").toDate(), }, }, // demo polls that are 1 day old { demo: true, createdAt: { - lte: addDays(new Date(), -1), + lte: dayjs().add(-1, "days").toDate(), }, }, ], diff --git a/src/server/routers/polls/demo.ts b/src/server/routers/polls/demo.ts index 4993777c7..864e29d3c 100644 --- a/src/server/routers/polls/demo.ts +++ b/src/server/routers/polls/demo.ts @@ -1,5 +1,5 @@ import { VoteType } from "@prisma/client"; -import addMinutes from "date-fns/addMinutes"; +import dayjs from "dayjs"; import { prisma } from "~/prisma/db"; @@ -31,7 +31,6 @@ export const demo = createRouter().mutation("create", { resolve: async () => { const adminUrlId = await nanoid(); const demoUser = { name: "John Example", email: "noreply@rallly.co" }; - const today = new Date(); const options: Array<{ value: string; id: string }> = []; @@ -59,7 +58,9 @@ export const demo = createRouter().mutation("create", { id: participantId, name, userId: "user-demo", - createdAt: addMinutes(today, i * -1), + createdAt: dayjs() + .add(i * -1, "minutes") + .toDate(), }); options.forEach((option, index) => { diff --git a/src/utils/date-time-utils.ts b/src/utils/date-time-utils.ts index 6b514d591..54df98a42 100644 --- a/src/utils/date-time-utils.ts +++ b/src/utils/date-time-utils.ts @@ -1,14 +1,5 @@ import { Option } from "@prisma/client"; -import { - differenceInHours, - differenceInMinutes, - format, - formatDuration, - isSameDay, - Locale, -} from "date-fns"; -import { formatInTimeZone } from "date-fns-tz"; -import spacetime from "spacetime"; +import dayjs from "dayjs"; import { DateTimeOption, @@ -49,19 +40,28 @@ export type ParsedDateTimeOpton = ParsedDateOption | ParsedTimeSlotOption; const isTimeSlot = (value: string) => value.indexOf("/") !== -1; -const getDuration = (startTime: Date, endTime: Date) => { - const hours = Math.floor(differenceInHours(endTime, startTime)); - const minutes = Math.floor( - differenceInMinutes(endTime, startTime) - hours * 60, - ); - return formatDuration({ hours, minutes }); +export const getDuration = (startTime: dayjs.Dayjs, endTime: dayjs.Dayjs) => { + const hours = Math.floor(endTime.diff(startTime, "hours")); + const minutes = Math.floor(endTime.diff(startTime, "minute") - hours * 60); + let res = ""; + if (hours) { + res += `${hours}h`; + } + if (hours && minutes) { + res += " "; + } + if (minutes) { + res += `${minutes}m`; + } + return res; }; export const decodeOptions = ( options: Option[], timeZone: string | null, targetTimeZone: string, - locale: Locale, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _timeFormat: string, // TODO (Luke Vella) [2022-06-28]: Need to pass timeFormat so that we recalculate the options when timeFormat changes. There is definitely a better way to do this ): | { pollType: "date"; options: ParsedDateOption[] } | { pollType: "timeSlot"; options: ParsedTimeSlotOption[] } => { @@ -71,7 +71,7 @@ export const decodeOptions = ( return { pollType, options: options.map((option) => - parseTimeSlotOption(option, timeZone, targetTimeZone, locale), + parseTimeSlotOption(option, timeZone, targetTimeZone), ), }; } else { @@ -88,14 +88,14 @@ const parseDateOption = (option: Option): ParsedDateOption => { ? // we add the time because otherwise Date will assume UTC time which might change the day for some time zones option.value + "T00:00:00" : option.value; - const date = new Date(dateString); + const date = dayjs(dateString); return { type: "date", optionId: option.id, - day: format(date, "d"), - dow: format(date, "EEE"), - month: format(date, "MMM"), - year: format(date, "yyyy"), + day: date.format("D"), + dow: date.format("ddd"), + month: date.format("MMM"), + year: date.format("YYYY"), }; }; @@ -103,48 +103,29 @@ const parseTimeSlotOption = ( option: Option, timeZone: string | null, targetTimeZone: string, - locale: Locale, ): ParsedTimeSlotOption => { - const localeFormatInTimezone = ( - date: Date, - timezone: string, - formatString: string, - ) => { - return formatInTimeZone(date, timezone, formatString, { - locale, - }); - }; - const [start, end] = option.value.split("/"); - if (timeZone && targetTimeZone) { - const startDate = spacetime(start, timeZone).toNativeDate(); - const endDate = spacetime(end, timeZone).toNativeDate(); - return { - type: "timeSlot", - optionId: option.id, - startTime: localeFormatInTimezone(startDate, targetTimeZone, "p"), - endTime: localeFormatInTimezone(endDate, targetTimeZone, "p"), - day: localeFormatInTimezone(startDate, targetTimeZone, "d"), - dow: localeFormatInTimezone(startDate, targetTimeZone, "EEE"), - month: localeFormatInTimezone(startDate, targetTimeZone, "MMM"), - duration: getDuration(startDate, endDate), - year: localeFormatInTimezone(startDate, targetTimeZone, "yyyy"), - }; - } else { - const startDate = new Date(start); - const endDate = new Date(end); - return { - type: "timeSlot", - optionId: option.id, - startTime: format(startDate, "p"), - endTime: format(endDate, "p"), - day: format(startDate, "d"), - dow: format(startDate, "E"), - month: format(startDate, "MMM"), - duration: getDuration(startDate, endDate), - year: format(startDate, "yyyy"), - }; - } + + const startDate = + timeZone && targetTimeZone + ? dayjs(start).tz(timeZone, true).tz(targetTimeZone) + : dayjs(start); + const endDate = + timeZone && targetTimeZone + ? dayjs(end).tz(timeZone, true).tz(targetTimeZone) + : dayjs(end); + + return { + type: "timeSlot", + optionId: option.id, + startTime: startDate.format("LT"), + endTime: endDate.format("LT"), + day: startDate.format("D"), + dow: startDate.format("ddd"), + month: startDate.format("MMM"), + duration: getDuration(startDate, endDate), + year: startDate.format("YYYY"), + }; }; export const removeAllOptionsForDay = ( @@ -152,18 +133,19 @@ export const removeAllOptionsForDay = ( date: Date, ) => { return options.filter((option) => { - const optionDate = spacetime( + const optionDate = new Date( option.type === "date" ? option.date : option.start, - ).toNativeDate(); - return !isSameDay(date, optionDate); + ); + return !dayjs(date).isSame(optionDate, "day"); }); }; export const getDateProps = (date: Date) => { + const d = dayjs(date); return { - day: format(date, "d"), - dow: format(date, "E"), - month: format(date, "MMM"), + day: d.format("D"), + dow: d.format("ddd"), + month: d.format("MMM"), }; }; diff --git a/tests/house-keeping.spec.ts b/tests/house-keeping.spec.ts index 8ad2306e5..dabd6210a 100644 --- a/tests/house-keeping.spec.ts +++ b/tests/house-keeping.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; import { Prisma } from "@prisma/client"; -import { addDays } from "date-fns"; +import dayjs from "dayjs"; import { prisma } from "../prisma/db"; @@ -29,7 +29,7 @@ test.beforeAll(async ({ request, baseURL }) => { type: "date", userId: "user1", deleted: true, - deletedAt: addDays(new Date(), -6), + deletedAt: dayjs().add(-6, "days").toDate(), participantUrlId: "p2", adminUrlId: "a2", }, @@ -40,7 +40,7 @@ test.beforeAll(async ({ request, baseURL }) => { type: "date", userId: "user1", deleted: true, - deletedAt: addDays(new Date(), -7), + deletedAt: dayjs().add(-7, "days").toDate(), participantUrlId: "p3", adminUrlId: "a3", }, @@ -50,7 +50,7 @@ test.beforeAll(async ({ request, baseURL }) => { id: "still-active-poll", type: "date", userId: "user1", - touchedAt: addDays(new Date(), -29), + touchedAt: dayjs().add(-29, "days").toDate(), participantUrlId: "p4", adminUrlId: "a4", }, @@ -60,7 +60,7 @@ test.beforeAll(async ({ request, baseURL }) => { id: "inactive-poll", type: "date", userId: "user1", - touchedAt: addDays(new Date(), -30), + touchedAt: dayjs().add(-30, "days").toDate(), participantUrlId: "p5", adminUrlId: "a5", }, @@ -82,7 +82,7 @@ test.beforeAll(async ({ request, baseURL }) => { id: "demo-poll-old", type: "date", userId: "user1", - createdAt: addDays(new Date(), -2), + createdAt: dayjs().add(-2, "days").toDate(), participantUrlId: "p7", adminUrlId: "a7", }, diff --git a/yarn.lock b/yarn.lock index b655c9ffe..dfb041128 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2527,15 +2527,10 @@ date-arithmetic@^4.1.0: resolved "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz" integrity sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg== -date-fns-tz@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/date-fns-tz/-/date-fns-tz-1.2.2.tgz#89432b54ce3fa7d050a2039e997e5b6a96df35dd" - integrity sha512-vWtn44eEqnLbkACb7T5G5gPgKR4nY8NkNMOCyoY49NsRGHrcDmY2aysCyzDeA+u+vcDBn/w6nQqEDyouRs4m8w== - -date-fns@^2.28.0: - version "2.28.0" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" - integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== +dayjs@^1.11.3: + version "1.11.3" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.3.tgz#4754eb694a624057b9ad2224b67b15d552589258" + integrity sha512-xxwlswWOlGhzgQ4TKzASQkUhqERI3egRNqgV4ScR8wlANA/A9tZ7miXa44vTTKEq5l7vWoL5G57bG3zA+Kow0A== debug@4: version "4.3.3" @@ -5023,10 +5018,10 @@ sourcemap-codec@1.4.8, sourcemap-codec@^1.4.8: resolved "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== -spacetime@^7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/spacetime/-/spacetime-7.1.2.tgz#73a94f0ba7f0c0b2230259b5ccf78dd9fd34cd16" - integrity sha512-MUTgK9KU9gMXhZddwe0nlgFnCJXT4RfuIRqyo8VcUexZa94zkxk1WpVv3THgvMFz1+Hq9okoNiGTvj6qBdN4Cg== +spacetime@^7.1.4: + version "7.1.4" + resolved "https://registry.yarnpkg.com/spacetime/-/spacetime-7.1.4.tgz#f288e4dcecbb0ec56e76c3b2a09c55badc18cebe" + integrity sha512-ZzYuGjaMPE42p/fVU9Qcd+aMhgYzC83hgqMKyWlGILtponsLm5KJZgWzblLnOU8OblMQa3YXo/0IiZZ5JsTDfA== spdx-correct@^3.0.0: version "3.1.1"