This PR aims at improving readability in sentry and user experience with runtime errors. **GraphQL errors (and ApolloError)** 1. In sentry we have a lot of "Object captured as exception with keys: extensions, message" errors (2k over the last 90d), on which we have zero information. This is because in apollo-factory we were passing on GraphQL errors to sentry directly why sentry expects the structure of a JS Error. We are now changing that, rebuilding an Error object and attempting to help grouping by creating a fingerPrint based on error code and truncated operationName (same as we do in the back for 500 graphql errors). 2. In sentry we have a lot of ApolloError, who actually correspond to errors that should not be logged in sentry (Forbidden errors such as "Email is not verified"), or errors that are already tracked by back-end (Postgres errors such as "column xxx does not exist"). This is because ApolloErrors become unhandled rejections errors if they are not caught and automatically sent to sentry through the basic config. To change that we are now filtering out ApolloErrors created from GraphQL Errors before sending error to sentry: <img width="524" alt="image" src="https://github.com/user-attachments/assets/02974829-26d9-4a9e-8c4c-cfe70155e4ab" /> **Runtime errors** 4. Runtime errors were all caught by sentry with the name "Error", making them not easy to differentiate on sentry (they were not grouped together but all appeared in the list as "Error"). We are replacing the "Error" name with the error message, or the error code if present. We are introducing a CustomError class that allows errors whose message contain dynamic text (an id for instance) to be identified on sentry with a common code. _(TODO: if this approach is validated then I have yet to replace Error with dynamic error messages with CustomError)_ 5. Runtime error messages contain technical details that do not mean anything to users (for instance, "Invalid folder ID: ${droppableId}", "ObjectMetadataItem not found", etc.). Let's replace them with "Please refresh the page." to users and keep the message error for sentry and our dev experience (they will still show in the console as uncaught errors).
244 lines
6.6 KiB
TypeScript
244 lines
6.6 KiB
TypeScript
/* eslint-disable @nx/workspace-explicit-boolean-predicates-in-if */
|
|
import { isDate, isNumber, isString } from '@sniptt/guards';
|
|
import { differenceInCalendarDays, formatDistanceToNow } from 'date-fns';
|
|
import { DateTime } from 'luxon';
|
|
import moize from 'moize';
|
|
|
|
import { DateFormat } from '@/localization/constants/DateFormat';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
import { CustomError } from '@/error-handler/CustomError';
|
|
import { logError } from './logError';
|
|
|
|
export const DEFAULT_DATE_LOCALE = 'en-EN';
|
|
|
|
export const parseDate = (dateToParse: Date | string | number) => {
|
|
if (dateToParse === 'now') return DateTime.fromJSDate(new Date());
|
|
|
|
let formattedDate: DateTime | null = null;
|
|
|
|
if (!dateToParse) {
|
|
throw new CustomError(
|
|
`Invalid date passed to formatPastDate: "${dateToParse}"`,
|
|
'INVALID_DATE_FORMAT',
|
|
);
|
|
} else if (isString(dateToParse)) {
|
|
formattedDate = DateTime.fromISO(dateToParse);
|
|
} else if (isDate(dateToParse)) {
|
|
formattedDate = DateTime.fromJSDate(dateToParse);
|
|
} else if (isNumber(dateToParse)) {
|
|
formattedDate = DateTime.fromMillis(dateToParse);
|
|
}
|
|
|
|
if (!formattedDate) {
|
|
throw new CustomError(
|
|
`Invalid date passed to formatPastDate: "${dateToParse}"`,
|
|
'INVALID_DATE_FORMAT',
|
|
);
|
|
}
|
|
|
|
if (!formattedDate.isValid) {
|
|
throw new CustomError(
|
|
`Invalid date passed to formatPastDate: "${dateToParse}"`,
|
|
'INVALID_DATE_FORMAT',
|
|
);
|
|
}
|
|
|
|
return formattedDate.setLocale(DEFAULT_DATE_LOCALE);
|
|
};
|
|
|
|
const isSameDay = (a: DateTime, b: DateTime): boolean =>
|
|
a.hasSame(b, 'day') && a.hasSame(b, 'month') && a.hasSame(b, 'year');
|
|
|
|
export const formatDate = (
|
|
dateToFormat: Date | string | number,
|
|
format: string,
|
|
) => {
|
|
try {
|
|
const parsedDate = parseDate(dateToFormat);
|
|
return parsedDate.toFormat(format);
|
|
} catch (error) {
|
|
logError(error);
|
|
return '';
|
|
}
|
|
};
|
|
|
|
export const beautifyExactDateTime = (
|
|
dateToBeautify: Date | string | number,
|
|
) => {
|
|
const isToday = isSameDay(parseDate(dateToBeautify), DateTime.local());
|
|
const dateFormat = isToday ? 'T' : 'DD · T';
|
|
return formatDate(dateToBeautify, dateFormat);
|
|
};
|
|
|
|
export const beautifyExactDate = (dateToBeautify: Date | string | number) => {
|
|
const isToday = isSameDay(parseDate(dateToBeautify), DateTime.local());
|
|
const dateFormat = isToday ? "'Today'" : 'DD';
|
|
return formatDate(dateToBeautify, dateFormat);
|
|
};
|
|
|
|
export const beautifyPastDateRelativeToNow = (
|
|
pastDate: Date | string | number,
|
|
) => {
|
|
try {
|
|
const parsedDate = parseDate(pastDate);
|
|
|
|
return formatDistanceToNow(parsedDate.toJSDate(), {
|
|
addSuffix: true,
|
|
}).replace('less than a minute ago', 'now');
|
|
} catch (error) {
|
|
logError(error);
|
|
return '';
|
|
}
|
|
};
|
|
|
|
export const beautifyPastDateAbsolute = (pastDate: Date | string | number) => {
|
|
try {
|
|
const parsedPastDate = parseDate(pastDate);
|
|
|
|
const hoursDiff = parsedPastDate.diffNow('hours').negate().hours;
|
|
|
|
if (hoursDiff <= 24) {
|
|
return parsedPastDate.toFormat('HH:mm');
|
|
} else if (hoursDiff <= 7 * 24) {
|
|
return parsedPastDate.toFormat('cccc - HH:mm');
|
|
} else if (hoursDiff <= 365 * 24) {
|
|
return parsedPastDate.toFormat('MMMM d - HH:mm');
|
|
} else {
|
|
return parsedPastDate.toFormat('dd/MM/yyyy - HH:mm');
|
|
}
|
|
} catch (error) {
|
|
logError(error);
|
|
return '';
|
|
}
|
|
};
|
|
|
|
export const hasDatePassed = (date: Date | string | number) => {
|
|
try {
|
|
const parsedDate = parseDate(date);
|
|
|
|
return (
|
|
differenceInCalendarDays(
|
|
DateTime.local().toJSDate(),
|
|
parsedDate.toJSDate(),
|
|
) >= 1
|
|
);
|
|
} catch (error) {
|
|
logError(error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export const beautifyDateDiff = (
|
|
date: string,
|
|
dateToCompareWith?: string,
|
|
short = false,
|
|
) => {
|
|
const dateDiff = DateTime.fromISO(date).diff(
|
|
dateToCompareWith ? DateTime.fromISO(dateToCompareWith) : DateTime.now(),
|
|
['years', 'days'],
|
|
);
|
|
let result = '';
|
|
if (dateDiff.years) result = result + `${dateDiff.years} year`;
|
|
if (![0, 1].includes(dateDiff.years)) result = result + 's';
|
|
if (short && dateDiff.years) return result;
|
|
if (dateDiff.years && dateDiff.days) result = result + ' and ';
|
|
if (dateDiff.days) result = result + `${Math.floor(dateDiff.days)} day`;
|
|
if (![0, 1].includes(dateDiff.days)) result = result + 's';
|
|
return result;
|
|
};
|
|
|
|
const getMonthLabels = () => {
|
|
const formatter = new Intl.DateTimeFormat(undefined, {
|
|
month: 'short',
|
|
timeZone: 'UTC',
|
|
});
|
|
|
|
return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
|
.map((month) => {
|
|
const monthZeroFilled = month < 10 ? `0${month}` : month;
|
|
return new Date(`2017-${monthZeroFilled}-01T00:00:00+00:00`);
|
|
})
|
|
.map((date) => formatter.format(date));
|
|
};
|
|
|
|
export const getMonthLabelsMemoized = moize(getMonthLabels);
|
|
|
|
export const formatISOStringToHumanReadableDateTime = (date: string) => {
|
|
const monthLabels = getMonthLabelsMemoized();
|
|
|
|
if (!isDefined(monthLabels)) {
|
|
return formatToHumanReadableDateTime(date);
|
|
}
|
|
|
|
const year = date.slice(0, 4);
|
|
const month = date.slice(5, 7);
|
|
|
|
const monthLabel = monthLabels[parseInt(month, 10) - 1];
|
|
|
|
const jsDate = new Date(date);
|
|
|
|
const day = jsDate.getDate();
|
|
|
|
const hours = `0${jsDate.getHours()}`.slice(-2);
|
|
|
|
const minutes = `0${jsDate.getMinutes()}`.slice(-2);
|
|
|
|
return `${day} ${monthLabel} ${year} - ${hours}:${minutes}`;
|
|
};
|
|
|
|
export const formatISOStringToHumanReadableDate = (date: string) => {
|
|
const monthLabels = getMonthLabelsMemoized();
|
|
|
|
if (!isDefined(monthLabels)) {
|
|
return formatToHumanReadableDate(date);
|
|
}
|
|
|
|
const year = date.slice(0, 4);
|
|
const month = date.slice(5, 7);
|
|
const day = date.slice(8, 10);
|
|
|
|
const monthLabel = monthLabels[parseInt(month, 10) - 1];
|
|
|
|
return `${day} ${monthLabel} ${year}`;
|
|
};
|
|
|
|
export const formatToHumanReadableDate = (date: Date | string) => {
|
|
const parsedJSDate = parseDate(date).toJSDate();
|
|
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
}).format(parsedJSDate);
|
|
};
|
|
|
|
export const formatToHumanReadableDateTime = (date: Date | string) => {
|
|
const parsedJSDate = parseDate(date).toJSDate();
|
|
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: 'numeric',
|
|
}).format(parsedJSDate);
|
|
};
|
|
|
|
export const getDateFormatString = (
|
|
dateFormat: DateFormat,
|
|
isDateTimeInput: boolean,
|
|
): string => {
|
|
const timePart = isDateTimeInput ? ' HH:mm' : '';
|
|
|
|
switch (dateFormat) {
|
|
case DateFormat.DAY_FIRST:
|
|
return `dd/MM/yyyy${timePart}`;
|
|
case DateFormat.YEAR_FIRST:
|
|
return `yyyy-MM-dd${timePart}`;
|
|
case DateFormat.MONTH_FIRST:
|
|
default:
|
|
return `MM/dd/yyyy${timePart}`;
|
|
}
|
|
};
|