mirror of
https://github.com/documenso/documenso.git
synced 2026-10-02 17:54:28 +10:00
175 lines
5.1 KiB
TypeScript
175 lines
5.1 KiB
TypeScript
import type {
|
|
TTeamAnalyticsRange,
|
|
TTeamAnalyticsResolvedRange,
|
|
} from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
|
import { ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK } from '@documenso/trpc/server/team-router/get-team-analytics.types';
|
|
import { DateTime, IANAZone } from 'luxon';
|
|
|
|
import { AppError, AppErrorCode } from '../errors/app-error';
|
|
|
|
const DAY_RANGE_LENGTHS: Record<Exclude<TTeamAnalyticsRange, '12m' | 'custom'>, number> = {
|
|
'7d': 7,
|
|
'30d': 30,
|
|
'90d': 90,
|
|
};
|
|
|
|
/** Custom ranges longer than this many days are bucketed by month instead of by day. */
|
|
const CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS = 92;
|
|
|
|
const DATE_FORMAT = 'yyyy-MM-dd';
|
|
|
|
export type ResolveTeamAnalyticsRangeOptions = {
|
|
range: TTeamAnalyticsRange;
|
|
/** Inclusive start date (yyyy-MM-dd) in `timezone`; required when `range` is `custom`. */
|
|
from?: string;
|
|
/** Inclusive end date (yyyy-MM-dd) in `timezone`; required when `range` is `custom`. */
|
|
to?: string;
|
|
timezone: string;
|
|
now?: Date;
|
|
};
|
|
|
|
/**
|
|
* Resolve an analytics range into a half-open [start, end) window in the given IANA
|
|
* timezone, plus the equally sized window immediately preceding it.
|
|
*
|
|
* For presets `end` is always the start of tomorrow in the timezone so that today is
|
|
* included. For `custom` the window is [from, to] inclusive as calendar days.
|
|
*/
|
|
export const resolveTeamAnalyticsRange = ({
|
|
range,
|
|
from,
|
|
to,
|
|
timezone,
|
|
now = new Date(),
|
|
}: ResolveTeamAnalyticsRangeOptions): TTeamAnalyticsResolvedRange => {
|
|
if (!IANAZone.isValidZone(timezone)) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Invalid analytics timezone',
|
|
});
|
|
}
|
|
|
|
const currentTime = DateTime.fromJSDate(now, { zone: timezone });
|
|
|
|
if (!currentTime.isValid) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Invalid analytics reference time',
|
|
});
|
|
}
|
|
|
|
const today = currentTime.startOf('day');
|
|
|
|
if (range === 'custom') {
|
|
return resolveCustomRange({ from, to, timezone, today });
|
|
}
|
|
|
|
const end = today.plus({ days: 1 });
|
|
|
|
if (range === '12m') {
|
|
const start = today.startOf('month').minus({ months: 11 });
|
|
|
|
return {
|
|
range,
|
|
from: start.toFormat(DATE_FORMAT),
|
|
to: today.toFormat(DATE_FORMAT),
|
|
timezone,
|
|
start: start.toJSDate(),
|
|
end: end.toJSDate(),
|
|
previousStart: start.minus({ months: 12 }).toJSDate(),
|
|
previousEnd: start.toJSDate(),
|
|
bucket: 'month',
|
|
};
|
|
}
|
|
|
|
const days = DAY_RANGE_LENGTHS[range];
|
|
const start = end.minus({ days });
|
|
const previousStart = start.minus({ days });
|
|
|
|
return {
|
|
range,
|
|
from: start.toFormat(DATE_FORMAT),
|
|
to: today.toFormat(DATE_FORMAT),
|
|
timezone,
|
|
start: start.toJSDate(),
|
|
end: end.toJSDate(),
|
|
previousStart: previousStart.toJSDate(),
|
|
previousEnd: start.toJSDate(),
|
|
bucket: 'day',
|
|
};
|
|
};
|
|
|
|
const resolveCustomRange = ({
|
|
from,
|
|
to,
|
|
timezone,
|
|
today,
|
|
}: {
|
|
from?: string;
|
|
to?: string;
|
|
timezone: string;
|
|
today: DateTime;
|
|
}): TTeamAnalyticsResolvedRange => {
|
|
if (!from || !to) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Custom analytics range requires both "from" and "to" dates',
|
|
});
|
|
}
|
|
|
|
const fromDate = parseCalendarDate(from, timezone, 'from');
|
|
const toDate = parseCalendarDate(to, timezone, 'to');
|
|
|
|
if (fromDate > toDate) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Custom analytics range "from" must not be after "to"',
|
|
});
|
|
}
|
|
|
|
if (toDate > today) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Custom analytics range "to" must not be in the future',
|
|
});
|
|
}
|
|
|
|
const earliestFrom = today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK);
|
|
|
|
if (fromDate < earliestFrom) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Custom analytics range must start within the last year',
|
|
});
|
|
}
|
|
|
|
const start = fromDate;
|
|
const end = toDate.plus({ days: 1 });
|
|
|
|
// Count calendar days rather than elapsed time so DST transitions do not skew the span.
|
|
const spanDays = Math.round(end.diff(start, 'days').days);
|
|
|
|
const previousStart = start.minus({ days: spanDays });
|
|
|
|
return {
|
|
range: 'custom',
|
|
from: start.toFormat(DATE_FORMAT),
|
|
to: toDate.toFormat(DATE_FORMAT),
|
|
timezone,
|
|
start: start.toJSDate(),
|
|
end: end.toJSDate(),
|
|
previousStart: previousStart.toJSDate(),
|
|
previousEnd: start.toJSDate(),
|
|
bucket: spanDays > CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS ? 'month' : 'day',
|
|
};
|
|
};
|
|
|
|
/** Parse a strict yyyy-MM-dd calendar date at local midnight in `timezone`. */
|
|
const parseCalendarDate = (value: string, timezone: string, field: 'from' | 'to'): DateTime => {
|
|
const parsed = DateTime.fromISO(value, { zone: timezone });
|
|
|
|
// Round-trip guard: rejects non-date ISO strings (e.g. datetimes) and overflowing
|
|
// dates such as 2023-02-30 that luxon would otherwise flag as invalid anyway.
|
|
if (!parsed.isValid || parsed.toISODate() !== value) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: `Invalid custom analytics range "${field}" date, expected yyyy-MM-dd`,
|
|
});
|
|
}
|
|
|
|
return parsed.startOf('day');
|
|
};
|