Skip to content

Commit

Permalink
feat: ORV2-1992 Limit date range to 31 days for reports (#1230)
Browse files Browse the repository at this point in the history
  • Loading branch information
krishnan-aot authored Feb 28, 2024
1 parent f5fa0ab commit 749e294
Show file tree
Hide file tree
Showing 10 changed files with 243 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export const PaymentAndRefundDetail = () => {
reValidateMode: "onBlur",
});

const { watch, setValue } = formMethods;
const { watch, setValue, handleSubmit } = formMethods;

const issuedBy = watch("issuedBy");
const fromDateTime = watch("fromDateTime");
Expand Down Expand Up @@ -260,7 +260,7 @@ export const PaymentAndRefundDetail = () => {
variant="contained"
color="primary"
disabled={issuedBy.length === 0}
onClick={onClickViewReport}
onClick={handleSubmit(onClickViewReport)}
>
View Report
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const PaymentAndRefundSummary = () => {
reValidateMode: "onBlur",
});

const { watch } = formMethods;
const { watch, handleSubmit } = formMethods;

const issuedBy = watch("issuedBy");
const fromDateTime = watch("fromDateTime");
Expand Down Expand Up @@ -104,7 +104,7 @@ export const PaymentAndRefundSummary = () => {
aria-label="View Report"
variant="contained"
color="primary"
onClick={onClickViewReport}
onClick={handleSubmit(onClickViewReport)}
>
View Report
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,49 @@ import { FormControl, FormLabel } from "@mui/material";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs, { Dayjs } from "dayjs";
import duration from "dayjs/plugin/duration";
import { useFormContext } from "react-hook-form";

import { PaymentAndRefundSummaryFormData } from "../../types/types";
import { useCallback, useEffect } from "react";
import { RequiredOrNull } from "../../../../../common/types/common";
import { PaymentAndRefundSummaryFormData } from "../../types/types";

dayjs.extend(duration);
const THIRTY_ONE_DAYS = 31;
const roundingBuffer = dayjs.duration(1, "hour").asDays();

/**
* The date time pickers for reports.
*/
export const ReportDateTimePickers = () => {
const { setValue, watch } = useFormContext<PaymentAndRefundSummaryFormData>();
const { setValue, watch, setError, formState, clearErrors } =
useFormContext<PaymentAndRefundSummaryFormData>();
const { errors } = formState;
const issuedBy = watch("issuedBy");
const fromDateTime = watch("fromDateTime");
const toDateTime = watch("toDateTime");

/**
* Validates the 'toDateTime' field by comparing it with 'fromDateTime'.
* This function checks if the difference between 'toDateTime' and 'fromDateTime'
* falls within a valid range. The valid range is greater than 0 days and up to 30 days
* plus a rounding buffer of 1 hour represented in days. If the difference is outside
* this valid range, an error is set for 'toDateTime'. If the difference is within the
* valid range, any existing error for 'toDateTime' is cleared.
*/
const validateToDateTime = useCallback(() => {
const diff = dayjs.duration(toDateTime.diff(fromDateTime)).asDays();
if (diff <= 0 || diff > THIRTY_ONE_DAYS + roundingBuffer) {
setError("toDateTime", {});
} else {
clearErrors("toDateTime");
}
}, [fromDateTime, toDateTime]);

useEffect(() => {
validateToDateTime();
}, [fromDateTime, toDateTime]);

return (
<>
<FormControl
Expand All @@ -30,12 +62,8 @@ export const ReportDateTimePickers = () => {
</FormLabel>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateTimePicker
defaultValue={dayjs()
.subtract(1, "day")
.set("h", 21)
.set("m", 0)
.set("s", 0)
.set("ms", 0)}
value={fromDateTime}
disableFuture
format="YYYY/MM/DD hh:mm A"
slotProps={{
digitalClockSectionItem: {
Expand Down Expand Up @@ -72,17 +100,30 @@ export const ReportDateTimePickers = () => {
setValue("toDateTime", value as Dayjs);
}}
format="YYYY/MM/DD hh:mm A"
defaultValue={dayjs()
.set("h", 20)
.set("m", 59)
.set("s", 59)
.set("ms", 999)}
value={toDateTime}
minDate={fromDateTime}
/**
* In the scenario where a user wants to select a 30 day window,
* if the fromDateTime starts at 9:00PM Jan 1, then by default,
* the max toDateTime could be 8:59 PM Jan 30.
* However, forcing the user to pick 8:59 PM in the date time picker
* is an annoyance to them. Instead, we allow them an additional minute so that
* 9:00 PM is the hard limit. This way, they just have to select the date
* and can ignore the time if they choose to.
*
* The reports API account for a rounding value which allows this buffer.
*
* Hence the decision to add 1 minute to 30 days, to make life easier for user.
*/
maxDateTime={fromDateTime.add(THIRTY_ONE_DAYS, "days").add(1, "minute")}
views={["year", "month", "day", "hours", "minutes"]}
// slotProps={{
// textField: {
// helperText: "Select a from date time",
// },
// }}
slotProps={{
textField: {
helperText:
errors.toDateTime &&
"To date time must be after From date time. Maximum date range is 30 days.",
},
}}
/>
</LocalizationProvider>
</FormControl>
Expand Down
46 changes: 46 additions & 0 deletions vehicles/src/common/constraint/date-range.constraint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { differenceBetween, getDuration } from '../helper/date-time.helper';
import { MaxDifferenceType } from '../interface/duration-difference.interface';
/**
* The constraint implementation for checking if a datetime is after
* another datetime and within the allowable difference.
*/
@ValidatorConstraint({ name: 'DateRange', async: false })
export class DateRangeConstraint<T> implements ValidatorConstraintInterface {
validate(toDateTime: string, args: ValidationArguments) {
// Some destructuring
const { constraints, object } = args;
const [
propertyToCompareAgainst,
{
difference: { maxDiff, unit },
rounding,
},
] = constraints as [string, MaxDifferenceType];
const fromDateTime = (object as T)[propertyToCompareAgainst] as string;

const difference = differenceBetween(fromDateTime, toDateTime, unit);

// Transform the rounding duration to a unit specified by difference
// to allow direct comparison.
const roundingDuration = getDuration(rounding).as(unit);
return difference > 0 && difference <= maxDiff + roundingDuration;
}

defaultMessage({ property, constraints }: ValidationArguments) {
const [
propertyToCompareAgainst,
{
difference: { maxDiff, unit },
},
] = constraints as [string, MaxDifferenceType];
return (
`${property} must be after ${propertyToCompareAgainst}.` +
`Max difference allowed is ${maxDiff} ${unit}.`
);
}
}
8 changes: 4 additions & 4 deletions vehicles/src/common/constraint/suspend-comment.constraint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import { SuspendActivity } from '../enum/suspend-activity.enum';
@ValidatorConstraint({ name: 'SuspendComment', async: false })
export class SuspendCommentConstraint implements ValidatorConstraintInterface {
validate(comment: string | undefined, args: ValidationArguments) {
const suspendAcitivity = (
const suspendActivity = (
args.object as {
suspendAcitivity?: SuspendActivity;
suspendActivity?: SuspendActivity;
}
).suspendAcitivity; // Access the searchString property from the same object
).suspendActivity; // Access the searchString property from the same object

// If SuspendActivity.SUSPEND_COMPANY, comment should exists
if (suspendAcitivity === SuspendActivity.SUSPEND_COMPANY && !comment) {
if (suspendActivity === SuspendActivity.SUSPEND_COMPANY && !comment) {
return false;
}

Expand Down
29 changes: 29 additions & 0 deletions vehicles/src/common/decorator/is-date-time-after.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ValidationOptions, registerDecorator } from 'class-validator';
import { DateRangeConstraint } from '../constraint/date-range.constraint';
import { MaxDifferenceType } from '../interface/duration-difference.interface';

/**
* Decorator that validates if a date time property is after
* another property.
*
* @param propertyToCompareAgainst The name of the property to compare against
* @param validationOptions Validation options, if any.
* @returns A function that validates the input to the class.
*/
export function IsDateTimeAfter<T>(
propertyToCompareAgainst: string,
additionalConstraints?: MaxDifferenceType,
validationOptions?: ValidationOptions,
) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'IsDateTimeAfter',
target: object.constructor,
propertyName: propertyName,
constraints: [propertyToCompareAgainst, additionalConstraints],
options: validationOptions,
async: false,
validator: DateRangeConstraint<T>,
});
};
}
39 changes: 39 additions & 0 deletions vehicles/src/common/helper/date-time.helper.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import * as dayjs from 'dayjs';
import * as utc from 'dayjs/plugin/utc';
import * as timezone from 'dayjs/plugin/timezone';
import * as duration from 'dayjs/plugin/duration';
import { DurationDifference } from '../interface/duration-difference.interface';

dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(duration);

export const convertUtcToPt = (dateTime: Date | string, format: string) => {
const formattedDate = dayjs.utc(dateTime).tz('Canada/Pacific').format(format);
Expand All @@ -19,3 +22,39 @@ export const dateFormat = (dateTime: string, format: string) => {
const formattedDate = dayjs(dateTime).format(format);
return formattedDate;
};

/**
* Calculates the difference between two date times.
*
* @param fromDateTime The from dateTime as a string
* @param toDateTime The to dateTime as a string
* @param unit The unit to return the difference value in. Default is days.
* @returns A number with the following meaning:
* - Zero: from and to are equal.
* - Negative: to is before from.
* - Positive: to is after from.
*/
export const differenceBetween = (
fromDateTime: string,
toDateTime: string,
unit: duration.DurationUnitType = 'days',
): number => {
return dayjs
.duration(dayjs.utc(toDateTime).diff(dayjs.utc(fromDateTime)))
.as(unit);
};

/**
* Calculates the duration based on a maximum difference and unit.
*
* @param {DurationDifference} params An object with `maxDiff`, `unit` parameters.
* `maxDiff` is the maximum difference and `unit` is the unit of time.
* @returns {number} The duration in the specified unit.
*/

export const getDuration = ({
maxDiff = 0,
unit = 'days',
}: DurationDifference): duration.Duration => {
return dayjs.duration(maxDiff, unit);
};
31 changes: 31 additions & 0 deletions vehicles/src/common/interface/duration-difference.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { DurationUnitType } from 'dayjs/plugin/duration';

/**
* The type to specify a max allowable to difference between two datetimes.
*/
export type DurationDifference = {
/**
* The unit of comparison.
*/
unit: DurationUnitType;
/**
* The maximum allowable difference.
*/
maxDiff: number;
};

/**
* The type to define values for a max allowable difference between two datetimes.
*/
export type MaxDifferenceType = {
/**
* The max allowable difference between two datetimes.
*/
difference: DurationDifference;
/**
* The allowable rounding difference between two datetimes.
* Optional. If not given, the value given in `difference` field
* will be the hard limit.
*/
rounding?: DurationDifference;
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { PermitTypeReport } from '../../../../common/enum/permit-type.enum';
import { Type } from 'class-transformer';
import { PaymentCodesDto } from '../common/payment-codes.dto';
import { PermitIssuedBy } from '../../../../common/enum/permit-issued-by.enum';
import { IsDateTimeAfter } from '../../../../common/decorator/is-date-time-after';

export class CreatePaymentDetailedReportDto {
@AutoMap()
Expand Down Expand Up @@ -64,10 +65,23 @@ export class CreatePaymentDetailedReportDto {

@AutoMap()
@ApiProperty({
example: '2025-10-27T23:26:51.170Z',
description: 'Include records in the report till the given date and time',
example: '2023-10-27T23:26:51.170Z',
description:
'Include records in the report till the given date and time.' +
'The toDateTime must be after fromDateTime and the difference between' +
' fromDateTime and toDateTime must not be more than 31 days.',
})
@IsDateString()
@IsDateTimeAfter<CreatePaymentDetailedReportDto>('fromDateTime', {
difference: {
maxDiff: 31,
unit: 'days',
},
rounding: {
maxDiff: 1,
unit: 'hour',
},
})
toDateTime: string;

@AutoMap()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AutoMap } from '@automapper/classes';
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsDateString, IsEnum } from 'class-validator';
import { PermitIssuedBy } from '../../../../common/enum/permit-issued-by.enum';
import { IsDateTimeAfter } from '../../../../common/decorator/is-date-time-after';

export class CreatePaymentSummaryReportDto {
@AutoMap()
Expand All @@ -27,8 +28,21 @@ export class CreatePaymentSummaryReportDto {
@AutoMap()
@ApiProperty({
example: '2023-10-27T23:26:51.170Z',
description: 'Include records in the report till the given date and time',
description:
'Include records in the report till the given date and time.' +
'The difference between fromDateTime and toDateTime must not be' +
' more than 31 days.',
})
@IsDateString()
@IsDateTimeAfter<CreatePaymentSummaryReportDto>('fromDateTime', {
difference: {
maxDiff: 31,
unit: 'days',
},
rounding: {
maxDiff: 1,
unit: 'hour',
},
})
toDateTime: string;
}

0 comments on commit 749e294

Please sign in to comment.