Switch from date-fns to day.js (#213)

This commit is contained in:
Luke Vella 2022-06-28 18:36:23 +01:00 committed by GitHub
parent 368f324865
commit 707eae68c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 549 additions and 229 deletions

View file

@ -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",

View file

@ -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 = () => {
<div className="mb-1">
<span className="mr-1 text-slate-400">&bull;</span>
<span className="text-sm text-slate-500">
{formatRelative(
new Date(comment.createdAt),
Date.now(),
{
locale,
},
)}
{dayjs(new Date(comment.createdAt)).fromNow()}
</span>
</div>
<Dropdown

View file

@ -0,0 +1,357 @@
/* eslint-disable */
// @ts-nocheck
import { DateLocalizer } from "react-big-calendar";
const weekRangeFormat = ({ start, end }, culture, local) =>
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,
});
}

View file

@ -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<DateTimePickerProps> = ({
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<DateTimePickerProps> = ({
type: "timeSlot",
start: formatDateWithoutTz(selectedDate),
end: formatDateWithoutTz(
addMinutes(selectedDate, duration),
dayjs(selectedDate)
.add(duration, "minutes")
.toDate(),
),
};
@ -208,7 +210,9 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
);
}
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<DateTimePickerProps> = ({
<TimePicker
value={startDate}
onChange={(newStart) => {
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<DateTimePickerProps> = ({
]);
onNavigate(newStart);
onChangeDuration(
differenceInMinutes(newEnd, newStart),
dayjs(newEnd).diff(newStart, "minutes"),
);
}}
/>
<TimePicker
value={new Date(option.end)}
startFrom={addMinutes(startDate, 15)}
startFrom={dayjs(startDate)
.add(15, "minutes")
.toDate()}
onChange={(newEnd) => {
onChange([
...options.slice(0, index),
@ -291,7 +299,7 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
]);
onNavigate(newEnd);
onChangeDuration(
differenceInMinutes(newEnd, startDate),
dayjs(newEnd).diff(startDate, "minutes"),
);
}}
/>
@ -322,7 +330,9 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
type: "timeSlot",
start: startTime,
end: formatDateWithoutTz(
addMinutes(new Date(startTime), duration),
dayjs(new Date(startTime))
.add(duration, "minutes")
.toDate(),
),
},
]);

View file

@ -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<TimePickerProps> = ({
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<TimePickerProps> = ({
],
});
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<TimePickerProps> = ({
className={styleMenuItem}
value={optionValue.toISOString()}
>
{format(optionValue, "p", { locale })}
{optionValue.format("LT")}
</Listbox.Option>,
);
}
@ -77,9 +79,7 @@ const TimePicker: React.VoidFunctionComponent<TimePickerProps> = ({
<>
<div ref={reference} className={clsx("relative", className)}>
<Listbox.Button className="btn-default text-left">
<span className="grow truncate">
{format(value, "p", { locale })}
</span>
<span className="grow truncate">{dayjs(value).format("LT")}</span>
<span className="pointer-events-none ml-2 flex">
<ChevronDown className="h-5 w-5" />
</span>

View file

@ -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");
};

View file

@ -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<DateTimePickerProps> = ({
title,
options,
@ -28,30 +24,12 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
}) => {
const [scrollToTime, setScrollToTime] = React.useState<Date>();
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 (
<Calendar
key={timeFormat}
@ -106,11 +84,13 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
);
},
eventWrapper: (props) => {
const start = dayjs(props.event.start);
const end = dayjs(props.event.end);
return (
<div
// onClick prop doesn't work properly. Seems like some other element is cancelling the event before it reaches this element
onMouseUp={props.onClick}
className="absolute ml-1 max-h-full overflow-hidden rounded-md bg-green-100 bg-opacity-80 p-1 text-xs text-green-500 transition-colors hover:bg-opacity-50"
className="absolute ml-1 max-h-full overflow-hidden rounded-md bg-green-100 p-1 text-xs text-green-500 transition-colors"
style={{
top: `calc(${props.style?.top}% + 4px)`,
height: `calc(${props.style?.height}% - 8px)`,
@ -118,10 +98,8 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
width: `calc(${props.style?.width}%)`,
}}
>
<div>{format(props.event.start, "p", { locale })}</div>
<div className="w-full truncate font-bold">
{props.event.title}
</div>
<div>{start.format("LT")}</div>
<div className="font-semibold">{getDuration(start, end)}</div>
</div>
);
},
@ -158,15 +136,15 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
)}
>
<span className="mr-1 font-normal opacity-50">
{format(date, "E")}
{dayjs(date).format("ddd")}
</span>
<span className="font-medium">{format(date, "dd")}</span>
<span className="font-medium">{dayjs(date).format("DD")}</span>
</span>
);
},
},
timeSlotWrapper: ({ children }) => {
return <div className="h-12 text-xs text-gray-500">{children}</div>;
return <div className="h-8 text-xs text-gray-500">{children}</div>;
},
}}
step={15}
@ -182,12 +160,14 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
};
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(

View file

@ -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<Date[]>([]);
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);
}

View file

@ -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 = () => {
<div>
<div className="font-semibold leading-9">
<div className="text-sm uppercase text-slate-400">
{format(d, "E")}
{dayjs(d).format("ddd")}
</div>
<div className="text-2xl">{format(d, "dd")}</div>
<div className="text-2xl">{dayjs(d).format("DD")}</div>
<div className="text-xs font-medium uppercase text-slate-400/75">
{format(d, "MMM")}
{dayjs(d).format("MMM")}
</div>
</div>
</div>

View file

@ -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<PollContextValue>(() => {
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,
]);

View file

@ -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();

View file

@ -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 (
<div className="text-slate-500/75 lg:text-lg">
@ -45,9 +43,7 @@ const PollSubheader: React.VoidFunctionComponent = () => {
</div>
<span className="hidden md:inline">&nbsp;&bull;&nbsp;</span>
<span className="whitespace-nowrap">
{formatRelative(poll.createdAt, new Date(), {
locale,
})}
{dayjs(poll.createdAt).fromNow()}
</span>
</div>
);

View file

@ -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<TimeFormat>("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],
);

View file

@ -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 = () => {
</Link>
</div>
<div className="ml-7 text-sm text-slate-500">
{formatRelative(poll.createdAt, new Date())}
{dayjs(poll.createdAt).fromNow()}
</div>
</div>
</div>

View file

@ -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(),
},
},
],

View file

@ -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) => {

View file

@ -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"),
};
};

View file

@ -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",
},

View file

@ -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"