diff --git a/packages/database/src/changeHandlers/TaskCompletionHandler.js b/packages/database/src/changeHandlers/TaskCompletionHandler.js index cd9c5b5d2c..8a02708b03 100644 --- a/packages/database/src/changeHandlers/TaskCompletionHandler.js +++ b/packages/database/src/changeHandlers/TaskCompletionHandler.js @@ -41,7 +41,6 @@ export class TaskCompletionHandler extends ChangeHandler { dataTime: surveyResponse.data_time, })), ); - return this.models.task.find({ [QUERY_CONJUNCTIONS.AND]: { status: 'to_do', @@ -54,7 +53,7 @@ export class TaskCompletionHandler extends ChangeHandler { }, [QUERY_CONJUNCTIONS.RAW]: { sql: `${surveyIdAndEntityIdPairs - .map(() => `(survey_id = ? AND entity_id = ? AND created_at <= ?)`) + .map(() => `(task.survey_id = ? AND task.entity_id = ? AND created_at <= ?)`) .join(' OR ')}`, parameters: surveyIdAndEntityIdPairs.flatMap(({ surveyId, entityId, dataTime }) => [ surveyId, diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index 517c5c22ed..e2dc1241ca 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -56,7 +56,13 @@ export class TaskRecord extends DatabaseRecord { { joinWith: RECORDS.SURVEY, joinCondition: ['survey_id', `${RECORDS.SURVEY}.id`], - fields: { name: 'survey_name', code: 'survey_code' }, + fields: { name: 'survey_name', code: 'survey_code', project_id: 'project_id' }, + }, + { + joinWith: RECORDS.SURVEY_RESPONSE, + joinType: JOIN_TYPES.LEFT, + joinCondition: ['survey_response_id', `${RECORDS.SURVEY_RESPONSE}.id`], + fields: { data_time: 'data_time', timezone: 'timezone' }, }, ]; diff --git a/packages/datatrak-web-server/src/app/createApp.ts b/packages/datatrak-web-server/src/app/createApp.ts index 669c7bad65..8a0fa54053 100644 --- a/packages/datatrak-web-server/src/app/createApp.ts +++ b/packages/datatrak-web-server/src/app/createApp.ts @@ -53,6 +53,8 @@ import { SurveysRoute, SurveyUsersRequest, SurveyUsersRoute, + TaskMetricsRequest, + TaskMetricsRoute, TaskRequest, TaskRoute, TasksRequest, @@ -95,6 +97,7 @@ export async function createApp() { .get('project/:projectCode', handleWith(ProjectRoute)) .get('recentSurveys', handleWith(RecentSurveysRoute)) .get('activityFeed', handleWith(ActivityFeedRoute)) + .get('taskMetrics/:projectId', handleWith(TaskMetricsRoute)) .get('tasks', handleWith(TasksRoute)) .get('tasks/:taskId', handleWith(TaskRoute)) .get('surveyResponse/:id', handleWith(SingleSurveyResponseRoute)) diff --git a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/handleTaskCompletion.ts b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/handleTaskCompletion.ts index c0a6ad41ba..5e4dfa3936 100644 --- a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/handleTaskCompletion.ts +++ b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/handleTaskCompletion.ts @@ -30,7 +30,7 @@ export const handleTaskCompletion = async ( }, }, [QUERY_CONJUNCTIONS.RAW]: { - sql: `(survey_id = ? AND entity_id = ? AND created_at <= ?)`, + sql: `(task.survey_id = ? AND task.entity_id = ? AND task.created_at <= ?)`, parameters: [surveyId, entityId, dataTime], }, }, diff --git a/packages/datatrak-web-server/src/routes/TaskMetricsRoute.ts b/packages/datatrak-web-server/src/routes/TaskMetricsRoute.ts new file mode 100644 index 0000000000..9b6d466e7f --- /dev/null +++ b/packages/datatrak-web-server/src/routes/TaskMetricsRoute.ts @@ -0,0 +1,79 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { getOffsetForTimezone } from '@tupaia/utils'; +import { DatatrakWebTaskMetricsRequest, TaskStatus } from '@tupaia/types'; +import { QUERY_CONJUNCTIONS, RECORDS } from '@tupaia/database'; + +export type TaskMetricsRequest = Request< + DatatrakWebTaskMetricsRequest.Params, + DatatrakWebTaskMetricsRequest.ResBody, + DatatrakWebTaskMetricsRequest.ReqBody, + DatatrakWebTaskMetricsRequest.ReqQuery +>; + +export class TaskMetricsRoute extends Route { + public async buildResponse() { + const { params, models } = this.req; + const { projectId } = params; + const baseQuery = { 'survey.project_id': projectId }; + const baseJoin = { joinWith: RECORDS.SURVEY, joinCondition: ['survey.id', 'task.survey_id'] }; + + const unassignedTasks = await models.task.count( + { + ...baseQuery, + [QUERY_CONJUNCTIONS.RAW]: { + sql: `assignee_id IS NULL`, + }, + }, + baseJoin, + ); + + const overdueTasks = await models.task.count( + { + ...baseQuery, + due_date: { + comparator: '<=', + comparisonValue: new Date().getTime(), + }, + }, + baseJoin, + ); + + const completedTasks = await models.task.find( + // @ts-ignore + { + ...baseQuery, + status: TaskStatus.completed, + [QUERY_CONJUNCTIONS.RAW]: { + sql: `repeat_schedule IS NULL`, + }, + }, + { + columns: ['due_date', 'data_time', 'timezone', 'project_id'], + }, + ); + + const onTimeCompletedTasks = completedTasks.filter(record => { + if (!record.due_date || !record.data_time) { + return false; + } + const { data_time: dataTime, timezone } = record; + const offset = getOffsetForTimezone(timezone, new Date(dataTime)); + const formattedDate = `${dataTime.toString().replace(' ', 'T')}${offset}`; + return new Date(formattedDate).getTime() <= record.due_date; + }); + + const onTimeCompletionRate = (completedTasks.length / onTimeCompletedTasks.length) * 100 || 0; + + return { + unassignedTasks, + overdueTasks, + onTimeCompletionRate, + }; + } +} diff --git a/packages/datatrak-web-server/src/routes/index.ts b/packages/datatrak-web-server/src/routes/index.ts index 40a444787e..062f070954 100644 --- a/packages/datatrak-web-server/src/routes/index.ts +++ b/packages/datatrak-web-server/src/routes/index.ts @@ -27,6 +27,7 @@ export { LeaderboardRequest, LeaderboardRoute } from './LeaderboardRoute'; export { ActivityFeedRequest, ActivityFeedRoute } from './ActivityFeedRoute'; export { EntitiesRequest, EntitiesRoute } from './EntitiesRoute'; export { GenerateLoginTokenRequest, GenerateLoginTokenRoute } from './GenerateLoginTokenRoute'; +export { TaskMetricsRequest, TaskMetricsRoute } from './TaskMetricsRoute'; export { TasksRequest, TasksRoute } from './TasksRoute'; export { TaskRequest, TaskRoute } from './TaskRoute'; export { SurveyUsersRequest, SurveyUsersRoute } from './SurveyUsersRoute'; diff --git a/packages/datatrak-web/src/api/mutations/useCreateTask.ts b/packages/datatrak-web/src/api/mutations/useCreateTask.ts index be4d3de8f3..e4732c74a3 100644 --- a/packages/datatrak-web/src/api/mutations/useCreateTask.ts +++ b/packages/datatrak-web/src/api/mutations/useCreateTask.ts @@ -7,9 +7,11 @@ import { useMutation, useQueryClient } from 'react-query'; import { DatatrakWebTaskChangeRequest } from '@tupaia/types'; import { post } from '../api'; import { successToast } from '../../utils'; +import { useCurrentUserContext } from '../CurrentUserContext'; export const useCreateTask = (onSuccess?: () => void) => { const queryClient = useQueryClient(); + const { projectId } = useCurrentUserContext(); return useMutation( (data: DatatrakWebTaskChangeRequest.ReqBody) => { return post('tasks', { @@ -19,6 +21,7 @@ export const useCreateTask = (onSuccess?: () => void) => { { onSuccess: () => { queryClient.invalidateQueries('tasks'); + queryClient.invalidateQueries(['taskMetric', projectId]); successToast('Task successfully created'); if (onSuccess) onSuccess(); }, diff --git a/packages/datatrak-web/src/api/mutations/useEditTask.ts b/packages/datatrak-web/src/api/mutations/useEditTask.ts index 4fb206477c..49200e53cb 100644 --- a/packages/datatrak-web/src/api/mutations/useEditTask.ts +++ b/packages/datatrak-web/src/api/mutations/useEditTask.ts @@ -7,11 +7,13 @@ import { useMutation, useQueryClient } from 'react-query'; import { Task } from '@tupaia/types'; import { put } from '../api'; import { successToast } from '../../utils'; +import { useCurrentUserContext } from '../CurrentUserContext'; type PartialTask = Partial; export const useEditTask = (taskId?: Task['id'], onSuccess?: () => void) => { const queryClient = useQueryClient(); + const { projectId } = useCurrentUserContext(); return useMutation( (task: PartialTask) => { return put(`tasks/${taskId}`, { @@ -22,6 +24,7 @@ export const useEditTask = (taskId?: Task['id'], onSuccess?: () => void) => { onSuccess: () => { queryClient.invalidateQueries('tasks'); queryClient.invalidateQueries(['tasks', taskId]); + queryClient.invalidateQueries(['taskMetric', projectId]); successToast('Task updated successfully'); if (onSuccess) onSuccess(); }, diff --git a/packages/datatrak-web/src/api/queries/index.ts b/packages/datatrak-web/src/api/queries/index.ts index e4b77acc14..ec212f9a3a 100644 --- a/packages/datatrak-web/src/api/queries/index.ts +++ b/packages/datatrak-web/src/api/queries/index.ts @@ -21,6 +21,7 @@ export { useUserRewards } from './useUserRewards'; export { useActivityFeed, useCurrentProjectActivityFeed } from './useActivityFeed'; export { useProjectSurveys } from './useProjectSurveys'; export { useEntities } from './useEntities'; +export { useTaskMetrics } from './useTaskMetrics'; export { useTasks } from './useTasks'; export { useTask } from './useTask'; export { useSurveyUsers } from './useSurveyUsers'; diff --git a/packages/datatrak-web/src/api/queries/useTaskMetrics.ts b/packages/datatrak-web/src/api/queries/useTaskMetrics.ts new file mode 100644 index 0000000000..1c5d1b317e --- /dev/null +++ b/packages/datatrak-web/src/api/queries/useTaskMetrics.ts @@ -0,0 +1,18 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useQuery } from 'react-query'; +import { DatatrakWebTaskMetricsRequest } from '@tupaia/types'; +import { get } from '../api'; + +export const useTaskMetrics = (projectId?: string) => { + return useQuery( + ['taskMetric', projectId], + (): Promise => get(`taskMetrics/${projectId}`), + { + enabled: !!projectId, + }, + ); +}; diff --git a/packages/datatrak-web/src/components/TaskMetrics/TaskMetric.tsx b/packages/datatrak-web/src/components/TaskMetrics/TaskMetric.tsx new file mode 100644 index 0000000000..9612b1c3a0 --- /dev/null +++ b/packages/datatrak-web/src/components/TaskMetrics/TaskMetric.tsx @@ -0,0 +1,51 @@ +import { SpinningLoader } from '@tupaia/ui-components'; +import React from 'react'; +import styled from 'styled-components'; + +const MetricWrapper = styled.div` + display: flex; + border: 1px solid; + border-radius: 3px; + margin-inline: 0.5rem; + margin-block-end: auto; + ${({ theme }) => theme.breakpoints.down('xs')} { + width: inherit; + margin-block-start: 0.5rem; + margin-inline: 0; + } +`; + +const MetricNumber = styled.p` + font-size: 0.875rem; + line-height: 1.75; + padding: 0.5rem 1.75rem; + border-right: 1px solid ${({ theme }) => theme.palette.divider}; + min-width: 3rem; + padding-inline: 0.9rem; + align-content: center; + text-align: center; + font-weight: 500; + margin: 0; +`; + +const MetricText = styled.p` + line-height: 1.75; + letter-spacing: 0; + padding: 0.5rem 1.75rem; + padding-inline-end: 1.2rem; + padding-inline-start: 0.9rem; + font-weight: 500; + margin: 0; + ${({ theme }) => theme.breakpoints.up('lg')} { + min-width: 12rem; + } +`; + +export const TaskMetric = ({ number, text, isLoading }) => { + return ( + + {isLoading ? : number} + {text} + + ); +}; diff --git a/packages/datatrak-web/src/components/TaskMetrics/TaskMetrics.tsx b/packages/datatrak-web/src/components/TaskMetrics/TaskMetrics.tsx new file mode 100644 index 0000000000..25fa30c9d3 --- /dev/null +++ b/packages/datatrak-web/src/components/TaskMetrics/TaskMetrics.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import styled from 'styled-components'; +import { TaskMetric } from './TaskMetric'; +import { useCurrentUserContext, useTaskMetrics } from '../../api'; + +const TaskMetricsContainer = styled.div` + margin-block-end: 0; + gap: 0.5rem; + ${({ theme }) => theme.breakpoints.up('xs')} { + display: flex; + flex-direction: row; + flex-wrap: wrap; + } + ${({ theme }) => theme.breakpoints.down('xs')} { + width: inherit; + } +`; + +export const TaskMetrics = () => { + const { projectId } = useCurrentUserContext(); + const { data: metrics, isLoading } = useTaskMetrics(projectId); + return ( + + + + + + ); +}; diff --git a/packages/datatrak-web/src/components/TaskMetrics/index.ts b/packages/datatrak-web/src/components/TaskMetrics/index.ts new file mode 100644 index 0000000000..5de07b5d3d --- /dev/null +++ b/packages/datatrak-web/src/components/TaskMetrics/index.ts @@ -0,0 +1,6 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +export { TaskMetrics } from './TaskMetrics'; diff --git a/packages/datatrak-web/src/components/index.ts b/packages/datatrak-web/src/components/index.ts index 4731951a85..f4ae7fc9f5 100644 --- a/packages/datatrak-web/src/components/index.ts +++ b/packages/datatrak-web/src/components/index.ts @@ -20,3 +20,4 @@ export { TextInput } from './TextInput'; export { Tile, LoadingTile } from './Tile'; export { Toast } from './Toast'; export { TopProgressBar } from './TopProgressBar'; +export { TaskMetrics } from './TaskMetrics'; diff --git a/packages/datatrak-web/src/features/Tasks/TaskPageHeader.tsx b/packages/datatrak-web/src/features/Tasks/TaskPageHeader.tsx index a2217be8de..760badcbf1 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskPageHeader.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskPageHeader.tsx @@ -22,9 +22,8 @@ const BackButton = styled(Button)` const Wrapper = styled.div` padding-block: 0.7rem; display: flex; - align-items: center; + align-items: self-start; padding-inline-end: 2.7rem; - ${({ theme }) => theme.breakpoints.down('xs')} { flex-direction: column; align-items: flex-start; @@ -63,7 +62,8 @@ const ContentWrapper = styled.div` justify-content: flex-end; width: 100%; ${({ theme }) => theme.breakpoints.down('xs')} { - padding-inline-start: 1.5rem; + padding-inline-start: 1rem; + flex-direction: column; padding-inline-end: 0.6rem; } `; diff --git a/packages/datatrak-web/src/views/Tasks/TasksDashboardPage.tsx b/packages/datatrak-web/src/views/Tasks/TasksDashboardPage.tsx index 43d34f762f..258b7583b7 100644 --- a/packages/datatrak-web/src/views/Tasks/TasksDashboardPage.tsx +++ b/packages/datatrak-web/src/views/Tasks/TasksDashboardPage.tsx @@ -9,6 +9,7 @@ import { Add } from '@material-ui/icons'; import { Button } from '../../components'; import { CreateTaskModal, TaskPageHeader, TasksTable } from '../../features'; import { TasksContentWrapper } from '../../layout'; +import { TaskMetrics } from '../../components/TaskMetrics'; const ButtonContainer = styled.div` padding-block-end: 0.5rem; @@ -16,6 +17,10 @@ const ButtonContainer = styled.div` ${({ theme }) => theme.breakpoints.up('sm')} { margin-inline-start: auto; margin-block-start: 0; + padding-block-end: 0; + } + ${({ theme }) => theme.breakpoints.down('xs')} { + align-self: self-end; } `; @@ -44,6 +49,7 @@ export const TasksDashboardPage = () => { return ( <> + Create task diff --git a/packages/server-boilerplate/src/models/Task.ts b/packages/server-boilerplate/src/models/Task.ts index eca0d55c83..704b693d3f 100644 --- a/packages/server-boilerplate/src/models/Task.ts +++ b/packages/server-boilerplate/src/models/Task.ts @@ -6,6 +6,10 @@ import { TaskRecord as BaseTaskRecord, TaskModel as BaseTaskModel } from '@tupai import { Task } from '@tupaia/types'; import { Model } from './types'; -export interface TaskRecord extends Task, BaseTaskRecord {} +export interface TaskRecord extends Task, BaseTaskRecord { + project_id?: string | null; + data_time?: Date | null; + timezone?: string | null; +} export interface TaskModel extends Model {} diff --git a/packages/server-boilerplate/src/models/types.ts b/packages/server-boilerplate/src/models/types.ts index ad2502d1cf..3202cf9c45 100644 --- a/packages/server-boilerplate/src/models/types.ts +++ b/packages/server-boilerplate/src/models/types.ts @@ -75,6 +75,7 @@ export type QueryOptions = { sort?: string[]; rawSort?: string; joinWith?: string; + columns?: string[]; joinCondition?: [string, string]; }; diff --git a/packages/types/src/types/requests/datatrak-web-server/TaskMetricsRequest.ts b/packages/types/src/types/requests/datatrak-web-server/TaskMetricsRequest.ts new file mode 100644 index 0000000000..b97cc48686 --- /dev/null +++ b/packages/types/src/types/requests/datatrak-web-server/TaskMetricsRequest.ts @@ -0,0 +1,8 @@ +export type Params = Record; +export interface ResBody { + unassignedTasks: number; + overdueTasks: number; + onTimeCompletionRate: number; +} +export type ReqBody = Record; +export type ReqQuery = Record; diff --git a/packages/types/src/types/requests/datatrak-web-server/index.ts b/packages/types/src/types/requests/datatrak-web-server/index.ts index 9f64eb121e..598904707c 100644 --- a/packages/types/src/types/requests/datatrak-web-server/index.ts +++ b/packages/types/src/types/requests/datatrak-web-server/index.ts @@ -17,6 +17,7 @@ export * as DatatrakWebLeaderboardRequest from './LeaderboardRequest'; export * as DatatrakWebActivityFeedRequest from './ActivityFeedRequest'; export * as DatatrakWebGenerateLoginTokenRequest from './GenerateLoginTokenRequest'; export * as DatatrakWebEntityDescendantsRequest from './EntityDescendantsRequest'; +export * as DatatrakWebTaskMetricsRequest from './TaskMetricsRequest'; export * as DatatrakWebTasksRequest from './TasksRequest'; export * as DatatrakWebTaskRequest from './TaskRequest'; export * as DatatrakWebUsersRequest from './UsersRequest'; diff --git a/packages/types/src/types/requests/index.ts b/packages/types/src/types/requests/index.ts index 83af95642a..b93544c385 100644 --- a/packages/types/src/types/requests/index.ts +++ b/packages/types/src/types/requests/index.ts @@ -20,6 +20,7 @@ export { DatatrakWebActivityFeedRequest, DatatrakWebGenerateLoginTokenRequest, DatatrakWebEntityDescendantsRequest, + DatatrakWebTaskMetricsRequest, DatatrakWebTasksRequest, DatatrakWebTaskRequest, DatatrakWebUsersRequest, diff --git a/packages/ui-components/src/components/Button.tsx b/packages/ui-components/src/components/Button.tsx index 3bcbc8119f..43179a2cf2 100644 --- a/packages/ui-components/src/components/Button.tsx +++ b/packages/ui-components/src/components/Button.tsx @@ -11,7 +11,7 @@ import { OverrideableComponentProps } from '../types'; const StyledButton = styled(MuiButton)` line-height: 1.75; letter-spacing: 0; - padding: 0.5em 1.75em; + padding: 0.5rem 1.75rem; box-shadow: none; min-width: 3rem;