diff --git a/src/modules/Dashboard/api/api.types.ts b/src/modules/Dashboard/api/api.types.ts index 965b82ced2..e57b844ea6 100644 --- a/src/modules/Dashboard/api/api.types.ts +++ b/src/modules/Dashboard/api/api.types.ts @@ -1,9 +1,9 @@ +import { User } from 'modules/Auth/state'; import { ActivityId, AppletId } from 'shared/api'; -import { Activity, Item, SingleApplet, SubscaleSetting } from 'shared/state'; import { ParticipantTag, Roles } from 'shared/consts'; -import { RetentionPeriods, EncryptedAnswerSharedProps, ExportActivity } from 'shared/types'; +import { Activity, Item, SingleApplet, SubscaleSetting } from 'shared/state'; +import { EncryptedAnswerSharedProps, ExportActivity, RetentionPeriods } from 'shared/types'; import { Encryption } from 'shared/utils'; -import { User } from 'modules/Auth/state'; export type GetAppletsParams = { params: { @@ -273,6 +273,7 @@ export type DatavizEntity = { hasAnswer: boolean; lastAnswerDate: string | null; isPerformanceTask?: boolean; + periodicity?: string; }; export type SubmitDates = { diff --git a/src/modules/Dashboard/components/ActivityGrid/ActivityGrid.utils.tsx b/src/modules/Dashboard/components/ActivityGrid/ActivityGrid.utils.tsx index 99f7c425e8..02e5702dd8 100644 --- a/src/modules/Dashboard/components/ActivityGrid/ActivityGrid.utils.tsx +++ b/src/modules/Dashboard/components/ActivityGrid/ActivityGrid.utils.tsx @@ -1,5 +1,22 @@ import { t } from 'i18next'; +import { + isWithinInterval, + getDay, + getDate, + getYear, + getMonth, + getHours, + getMinutes, + getSeconds, + format, + isWeekend, + isAfter, + setDate, + setMonth, + setYear, +} from 'date-fns'; +import { Event } from 'modules/Dashboard/state'; import { Svg } from 'shared/components/Svg'; import { MenuItem, MenuItemType } from 'shared/components'; import { @@ -13,6 +30,163 @@ import { EditablePerformanceTasks } from 'modules/Builder/features/Activities/Ac import { ActivityActions, ActivityActionProps } from './ActivityGrid.types'; +/** + * this function checks if the current date is within the interval passed as arguments + * @param start Date + * @param end Date + * @returns boolean + */ +function datetimeIsWithinInterval(start: Date, end: Date) { + const today = new Date(); + if (isAfter(start, end)) { + console.error('The start date is after the end date, be sure to check the dates'); + + // using the isWithinInterval function with the start and end dates swapped + // in case the start date is after the end date + return isWithinInterval(today, { start: end, end: start }); + } + + return isWithinInterval(today, { start, end }); +} + +/** + * this function returns the values of the date represented as an object + * @param date Date + * @returns object + */ +function getDateValues(date: Date) { + if (!date || !(date instanceof Date)) { + console.error('The date is not valid, be sure to pass a valid date'); + + return { + day: 0, + month: 0, + year: 0, + hour: 0, + minute: 0, + second: 0, + dayInWeek: 0, + date: new Date(), + }; + } + + return { + day: getDate(date), + month: getMonth(date) + 1, + year: getYear(date), + hour: getHours(date), + minute: getMinutes(date), + second: getSeconds(date), + dayInWeek: getDay(date), + date, + }; +} + +const PERIODICITY_VALUES = { + ONCE: 'ONCE', + ALWAYS: 'ALWAYS', + DAILY: 'DAILY', + WEEKLY: 'WEEKLY', + WEEKDAYS: 'WEEKDAYS', + MONTHLY: 'MONTHLY', +}; + +/** + * this function formats the date as YYYY-MM-DD + * @param date Date + * @returns string + */ +function formatDateAsYYYYMMDD(date: Date) { + if (!date || !(date instanceof Date)) { + console.error('The date is not valid, be sure to pass a valid date'); + } + + return format(date, 'yyyy-MM-dd'); +} + +/** + * this function validates if the date is in range based on the PeriodicityTypes object + * the options of validation are: + * - if the periodicity is always, which means that the activity is always available + * - if the periodicity is once, which means that the activity is available only once + * - if the periodicity is daily, which means that the activity is available every day + * - if the periodicity is weekly, which means that the activity is available every week at the same week day + * - if the periodicity is weekdays, which means that the activity is available every weekday and not in weekends + * - if the periodicity is monthly, which means that the activity is available every month at the same day + * - if the periodicity is not set + * @param scheduleEvent PeriodicityType + * @returns boolean + */ +function validateIfDateIsInRange(scheduleEvent: Event) { + if (!scheduleEvent) { + console.error('The periodicity is not set, be sure to pass a valid periodicity'); + + return false; + } + + if ( + scheduleEvent.accessBeforeSchedule || + scheduleEvent.periodicity.type === PERIODICITY_VALUES.ALWAYS + ) { + return true; + } + + const currentDate = new Date(); + const currentTimeValues = getDateValues(currentDate); + const startDate = new Date( + `${ + scheduleEvent.periodicity.startDate + ? scheduleEvent.periodicity.startDate + : formatDateAsYYYYMMDD(currentDate) + }T${scheduleEvent.startTime}`, + ); + const endDate = new Date( + `${ + scheduleEvent?.periodicity.endDate + ? scheduleEvent.periodicity.endDate + : formatDateAsYYYYMMDD(currentDate) + }T${scheduleEvent.endTime}`, + ); + + const startTimeValues = getDateValues(startDate); + + if ( + scheduleEvent.periodicity.type === PERIODICITY_VALUES.ONCE || + scheduleEvent.periodicity.type === PERIODICITY_VALUES.DAILY + ) { + const todayEndDateWithEndTime = setYear( + setMonth(setDate(endDate, currentTimeValues.day), currentTimeValues.month - 1), + currentTimeValues.year, + ); + + return datetimeIsWithinInterval(startDate, todayEndDateWithEndTime); + } else if (scheduleEvent.periodicity.type === PERIODICITY_VALUES.WEEKLY) { + if (currentTimeValues.dayInWeek !== startTimeValues.dayInWeek) { + return false; + } + + return datetimeIsWithinInterval(startDate, endDate); + } else if (scheduleEvent.periodicity.type === PERIODICITY_VALUES.WEEKDAYS) { + if (isWeekend(currentDate)) { + return false; + } + + const todayEndDateWithEndTime = setYear( + setMonth(setDate(endDate, currentTimeValues.day), currentTimeValues.month - 1), + currentTimeValues.year, + ); + + return datetimeIsWithinInterval(startDate, todayEndDateWithEndTime); + } else if ( + scheduleEvent.periodicity.type === PERIODICITY_VALUES.MONTHLY && + currentTimeValues.day === startTimeValues.day + ) { + return datetimeIsWithinInterval(startDate, endDate); + } + + return false; +} + export const getActivityActions = ({ actions: { editActivity, exportData, assignActivity, takeNow }, appletId, @@ -34,7 +208,9 @@ export const getActivityActions = ({ const { id: activityId } = activity; const isWebUnsupported = !getIsWebSupported(activity.items); - if (!activityId) return []; + if (!activityId || !activity?.event) return []; + + const isInRange = validateIfDateIsInRange(activity?.event); return [ { @@ -68,8 +244,10 @@ export const getActivityActions = ({ title: t('takeNow.menuItem'), context: { appletId, activityId }, isDisplayed: canDoTakeNow, - disabled: isWebUnsupported, - tooltip: isWebUnsupported && t('activityIsMobileOnly'), + disabled: isWebUnsupported || !isInRange, + tooltip: + (!isInRange && t('activityIsUnavailableAtThisTime')) || + (isWebUnsupported && t('activityIsMobileOnly')), 'data-testid': `${dataTestId}-activity-take-now`, }, ]; diff --git a/src/modules/Dashboard/features/Applet/Activities/Activities.tsx b/src/modules/Dashboard/features/Applet/Activities/Activities.tsx index 5192f6d839..014c356b97 100644 --- a/src/modules/Dashboard/features/Applet/Activities/Activities.tsx +++ b/src/modules/Dashboard/features/Applet/Activities/Activities.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; -import { getAppletActivitiesApi } from 'api'; +import { getAppletActivitiesApi, getEventsApi } from 'api'; import { Spinner } from 'shared/components'; import { useAsync } from 'shared/hooks'; import { ActivityGrid, useActivityGrid } from 'modules/Dashboard/components/ActivityGrid'; @@ -17,6 +17,14 @@ import { ActivitiesToolbar } from './ActivitiesToolbar'; const dataTestId = 'dashboard-applet-activities'; +function mergeInfo(activities: Activity[], valueEvents: any) { + return activities.map((activity) => { + const event = valueEvents?.data?.result?.find((event: any) => event.activityId === activity.id); + + return { ...activity, event }; + }); +} + export const Activities = () => { const [activityId, setActivityId] = useState(); const [showExportPopup, setShowExportPopup] = useState(false); @@ -24,6 +32,11 @@ export const Activities = () => { const { appletId } = useParams(); const { t } = useTranslation('app'); const { execute, isLoading, value, previousValue } = useAsync(getAppletActivitiesApi); + const { + execute: executeEvents, + isLoading: loadingEvents, + value: valueEvents, + } = useAsync(getEventsApi); const activities: Activity[] = useMemo( () => (value ?? previousValue)?.data?.result?.activitiesDetails ?? [], @@ -31,13 +44,21 @@ export const Activities = () => { ); const flows: ActivityFlow[] = (value ?? previousValue)?.data?.result?.appletDetail?.activityFlows ?? []; + const showContent = - (isLoading && previousValue?.data?.result?.activitiesDetails?.length > 0) || !isLoading; + (isLoading && previousValue?.data?.result?.activitiesDetails?.length > 0) || + !isLoading || + !loadingEvents; + + const useMemoizedActivities = useMemo( + () => mergeInfo(activities, valueEvents), + [activities, valueEvents], + ); const { formatRow, TakeNowModal } = useActivityGrid( dataTestId, { - result: activities, + result: useMemoizedActivities, count: activities.length, }, useCallback((activityId: string) => { @@ -47,15 +68,16 @@ export const Activities = () => { ); const formattedActivities = useMemo( - () => activities.map((activity) => formatRow(activity)), - [activities, formatRow], + () => useMemoizedActivities.map((activity) => formatRow(activity)), + [useMemoizedActivities, formatRow], ); useEffect(() => { if (!appletId) return; execute({ params: { appletId } }); - }, [appletId, execute]); + executeEvents({ appletId }); + }, [appletId, execute, executeEvents]); return ( diff --git a/src/modules/Dashboard/features/Applet/Schedule/EventForm/EventForm.types.ts b/src/modules/Dashboard/features/Applet/Schedule/EventForm/EventForm.types.ts index 287b40475c..12d80b72fe 100644 --- a/src/modules/Dashboard/features/Applet/Schedule/EventForm/EventForm.types.ts +++ b/src/modules/Dashboard/features/Applet/Schedule/EventForm/EventForm.types.ts @@ -3,11 +3,11 @@ import { FormState } from 'react-hook-form'; import * as yup from 'yup'; import { - TimerType, - Periodicity, EventNotifications, EventReminder, NotificationType, + Periodicity, + TimerType, } from 'modules/Dashboard/api'; import { CalendarEvent } from 'modules/Dashboard/state'; diff --git a/src/modules/Dashboard/state/Applets/Applets.schema.ts b/src/modules/Dashboard/state/Applets/Applets.schema.ts index 2c7ffb6194..78eb3df8e3 100644 --- a/src/modules/Dashboard/state/Applets/Applets.schema.ts +++ b/src/modules/Dashboard/state/Applets/Applets.schema.ts @@ -1,14 +1,17 @@ +import { EventNotifications, EventReminder, Periodicity, TimerType } from 'api'; import { BaseSchema } from 'shared/state/Base'; -import { Periodicity, TimerType, EventNotifications, EventReminder } from 'api'; export type Event = { - startTime: string | null; - endTime: string | null; accessBeforeSchedule: boolean | null; - oneTimeCompletion: boolean | null; - timer: number | null; - timerType: TimerType; + activityId: string | null; + endTime: string | null; + flowId: string | null; id: string; + notification: { + notifications: EventNotifications; + reminder: EventReminder; + } | null; + oneTimeCompletion: boolean | null; periodicity: { type: Periodicity; startDate: string | null; @@ -16,12 +19,9 @@ export type Event = { selectedDate: string | null; }; respondentId: string | null; - activityId: string | null; - flowId: string | null; - notification: { - notifications: EventNotifications; - reminder: EventReminder; - } | null; + startTime: string | null; + timer: number | null; + timerType: TimerType; }; export type AppletsSchema = { diff --git a/src/resources/app-en.json b/src/resources/app-en.json index b492b657bf..9e3752de94 100644 --- a/src/resources/app-en.json +++ b/src/resources/app-en.json @@ -32,6 +32,7 @@ "activityImg": "Activity Image", "activityIncomplete": "Activity incomplete", "activityIsMobileOnly": "This activity must be completed on the Mindlogger mobile app.", + "activityIsUnavailableAtThisTime" : "This activity is currently unavailable for completion based on its schedule.", "activityIsRequired": "At least 1 activity is required.", "activityItemsFlow": "Item Flow", "activityItemsFlowDescription": "To determine the order of transition from one Item to another, an Item Flow can be created.", diff --git a/src/resources/app-fr.json b/src/resources/app-fr.json index d8406a83a9..dc0042bacd 100644 --- a/src/resources/app-fr.json +++ b/src/resources/app-fr.json @@ -33,6 +33,7 @@ "activityIncomplete": "Activité incomplète", "activityIsRequired": "Au moins 1 activité est requise.", "activityIsMobileOnly": "Cette activité doit être réalisée sur l'application mobile Mindlogger.", + "activityIsUnavailableAtThisTime" : "Cette activité est actuellement indisponible pour être complétée en fonction de son calendrier.", "activityItemsFlow": "Flux d'éléments", "activityItemsFlowDescription": "Pour déterminer l'ordre de transition d'un élément à un autre, un flux d'éléments peut être créé.", "activityItemsFlowItemTitle": "Conditionnel {{- index}}", diff --git a/src/shared/state/Applet/Applet.schema.ts b/src/shared/state/Applet/Applet.schema.ts index 2825d80e79..703f7691cd 100644 --- a/src/shared/state/Applet/Applet.schema.ts +++ b/src/shared/state/Applet/Applet.schema.ts @@ -1,26 +1,27 @@ import { ColorResult } from 'react-color'; -import { BaseSchema } from 'shared/state/Base'; -import { ElementType, RetentionPeriods } from 'shared/types'; import { - ItemResponseType, - SubscaleTotalScore, + CorrectPress, + DeviceType, + FlankerSamplingMethod, + OrderName, + RoundTypeEnum, +} from 'modules/Builder/types'; +import { Event } from 'modules/Dashboard/state'; +import { + CalculationType, ConditionType, - ScoreConditionType, ConditionalLogicMatch, - CalculationType, - PerfTaskType, GyroscopeOrTouch, + ItemResponseType, + PerfTaskType, + ScoreConditionType, ScoreReportType, + SubscaleTotalScore, } from 'shared/consts'; +import { BaseSchema } from 'shared/state/Base'; +import { ElementType, RetentionPeriods } from 'shared/types'; import { Encryption } from 'shared/utils/encryption'; -import { - CorrectPress, - RoundTypeEnum, - FlankerSamplingMethod, - DeviceType, - OrderName, -} from 'modules/Builder/types'; type ActivityFlowItem = { activityId: string; @@ -661,26 +662,56 @@ export type ScoresObject = { [key: string]: number; }; +// types from the BE repo defined in src/apps/schedule/domain/constants.py with the same name as the following: +export type PeriodicityTypeValues = { + ONCE: 'ONCE'; + DAILY: 'DAILY'; + WEEKLY: 'WEEKLY'; + WEEKDAYS: 'WEEKDAYS'; + MONTHLY: 'MONTHLY'; + ALWAYS: 'ALWAYS'; +}; + +export type TimerType = { + NOT_SET: 'NOT_SET'; + TIMER: 'TIMER'; + IDLE: 'IDLE'; +}; + +export type PeriodicityType = { + access_before_schedule: boolean; + end_date: string; // Format: "YYYY-MM-DD" + end_time: string; // Format: "HH:MM:SS" + one_time_completion: null | string; + selected_date: null | string; + start_date: string; // Format: "YYYY-MM-DD" + start_time: string; // Format: "HH:MM:SS" + timer: null | number; + timer_type: TimerType[keyof TimerType]; + type: PeriodicityTypeValues[keyof PeriodicityTypeValues]; +}; + export type Activity = { + createdAt?: string; + description: string | Record; id?: string; + image?: string; + isHidden?: boolean; + isPerformanceTask?: boolean; + isReviewable?: boolean; + isSkippable?: boolean; + items: Item[]; key?: string; name: string; order?: number; - description: string | Record; - splashScreen?: string; - image?: string; - showAllAtOnce?: boolean; - isSkippable?: boolean; - isReviewable?: boolean; + performanceTaskType?: PerfTaskType | null; + event?: Event; + reportIncludedItemName?: string; responseIsEditable?: boolean; - isHidden?: boolean; - items: Item[]; scoresAndReports?: ScoresAndReports; + showAllAtOnce?: boolean; + splashScreen?: string; subscaleSetting?: SubscaleSetting | null; - isPerformanceTask?: boolean; - performanceTaskType?: PerfTaskType | null; - createdAt?: string; - reportIncludedItemName?: string; }; type Theme = {