Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(datatrakWeb): RN-1374: Task Metrics #5860

Merged
merged 12 commits into from
Aug 29, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export class TaskCompletionHandler extends ChangeHandler {
dataTime: surveyResponse.data_time,
})),
);

return this.models.task.find({
[QUERY_CONJUNCTIONS.AND]: {
status: 'to_do',
Expand All @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion packages/database/src/modelClasses/Task.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
},
];

Expand Down
3 changes: 3 additions & 0 deletions packages/datatrak-web-server/src/app/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import {
SurveysRoute,
SurveyUsersRequest,
SurveyUsersRoute,
TaskMetricsRequest,
TaskMetricsRoute,
TaskRequest,
TaskRoute,
TasksRequest,
Expand Down Expand Up @@ -95,6 +97,7 @@ export async function createApp() {
.get<ProjectRequest>('project/:projectCode', handleWith(ProjectRoute))
.get<RecentSurveysRequest>('recentSurveys', handleWith(RecentSurveysRoute))
.get<ActivityFeedRequest>('activityFeed', handleWith(ActivityFeedRoute))
.get<TaskMetricsRequest>('taskMetrics/:projectId', handleWith(TaskMetricsRoute))
.get<TasksRequest>('tasks', handleWith(TasksRoute))
.get<TaskRequest>('tasks/:taskId', handleWith(TaskRoute))
.get<SingleSurveyResponseRequest>('surveyResponse/:id', handleWith(SingleSurveyResponseRoute))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
},
},
Expand Down
79 changes: 79 additions & 0 deletions packages/datatrak-web-server/src/routes/TaskMetricsRoute.ts
Original file line number Diff line number Diff line change
@@ -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<TaskMetricsRequest> {
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,
};
}
}
1 change: 1 addition & 0 deletions packages/datatrak-web-server/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 3 additions & 0 deletions packages/datatrak-web/src/api/mutations/useCreateTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, Error, DatatrakWebTaskChangeRequest.ReqBody, unknown>(
(data: DatatrakWebTaskChangeRequest.ReqBody) => {
return post('tasks', {
Expand All @@ -19,6 +21,7 @@ export const useCreateTask = (onSuccess?: () => void) => {
{
onSuccess: () => {
queryClient.invalidateQueries('tasks');
queryClient.invalidateQueries(['taskMetric', projectId]);
successToast('Task successfully created');
if (onSuccess) onSuccess();
},
Expand Down
3 changes: 3 additions & 0 deletions packages/datatrak-web/src/api/mutations/useEditTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task>;

export const useEditTask = (taskId?: Task['id'], onSuccess?: () => void) => {
const queryClient = useQueryClient();
const { projectId } = useCurrentUserContext();
return useMutation<any, Error, PartialTask, unknown>(
(task: PartialTask) => {
return put(`tasks/${taskId}`, {
Expand All @@ -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();
},
Expand Down
1 change: 1 addition & 0 deletions packages/datatrak-web/src/api/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
18 changes: 18 additions & 0 deletions packages/datatrak-web/src/api/queries/useTaskMetrics.ts
Original file line number Diff line number Diff line change
@@ -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<DatatrakWebTaskMetricsRequest.ResBody> => get(`taskMetrics/${projectId}`),
{
enabled: !!projectId,
},
);
};
51 changes: 51 additions & 0 deletions packages/datatrak-web/src/components/TaskMetrics/TaskMetric.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<MetricWrapper>
<MetricNumber>{isLoading ? <SpinningLoader spinnerSize={14} /> : number}</MetricNumber>
<MetricText>{text}</MetricText>
</MetricWrapper>
);
};
33 changes: 33 additions & 0 deletions packages/datatrak-web/src/components/TaskMetrics/TaskMetrics.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TaskMetricsContainer>
<TaskMetric text="Unassigned tasks" number={metrics?.unassignedTasks} isLoading={isLoading} />
<TaskMetric text="Overdue tasks" number={metrics?.overdueTasks} isLoading={isLoading} />
<TaskMetric
text="On-time completion rate"
number={`${metrics?.onTimeCompletionRate}%`}
isLoading={isLoading}
/>
</TaskMetricsContainer>
);
};
6 changes: 6 additions & 0 deletions packages/datatrak-web/src/components/TaskMetrics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/*
* Tupaia
* Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd
*/

export { TaskMetrics } from './TaskMetrics';
1 change: 1 addition & 0 deletions packages/datatrak-web/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 3 additions & 3 deletions packages/datatrak-web/src/features/Tasks/TaskPageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
`;
Expand Down
6 changes: 6 additions & 0 deletions packages/datatrak-web/src/views/Tasks/TasksDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ 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;
margin-block-start: 1rem;
${({ 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;
}
`;

Expand Down Expand Up @@ -44,6 +49,7 @@ export const TasksDashboardPage = () => {
return (
<>
<TaskPageHeader title="Tasks" backTo="/">
<TaskMetrics />
<ButtonContainer>
<CreateButton onClick={toggleCreateModal}>
<AddIcon /> Create task
Expand Down
6 changes: 5 additions & 1 deletion packages/server-boilerplate/src/models/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BaseTaskModel, Task, TaskRecord> {}
1 change: 1 addition & 0 deletions packages/server-boilerplate/src/models/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export type QueryOptions = {
sort?: string[];
rawSort?: string;
joinWith?: string;
columns?: string[];
joinCondition?: [string, string];
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type Params = Record<string, never>;
export interface ResBody {
unassignedTasks: number;
overdueTasks: number;
onTimeCompletionRate: number;
}
export type ReqBody = Record<string, never>;
export type ReqQuery = Record<string, never>;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading