diff --git a/__tests__/Unit/Components/ExtensionRequest/ExtensionStatusModal.test.tsx b/__tests__/Unit/Components/ExtensionRequest/ExtensionStatusModal.test.tsx index 4996dabf2..e3eee08f0 100644 --- a/__tests__/Unit/Components/ExtensionRequest/ExtensionStatusModal.test.tsx +++ b/__tests__/Unit/Components/ExtensionRequest/ExtensionStatusModal.test.tsx @@ -120,9 +120,11 @@ describe('ExtensionStatusModal Component', () => { }); test('should test formatToRelativeTime function', () => { + jest.useFakeTimers().setSystemTime(new Date('2025-01-01T00:00:00Z')); const timestamp = 1640995200; const result = formatToRelativeTime(timestamp); expect(result).toBe('3 years ago'); + jest.useRealTimers(); }); test('should open extension request form when request extension button is clicked', () => { diff --git a/__tests__/Unit/Components/Tasks/Card.test.tsx b/__tests__/Unit/Components/Tasks/Card.test.tsx index 025f1cbfa..8cbec970f 100644 --- a/__tests__/Unit/Components/Tasks/Card.test.tsx +++ b/__tests__/Unit/Components/Tasks/Card.test.tsx @@ -23,6 +23,7 @@ import { NEEDS_REVIEW, VERIFIED, } from '@/constants/task-status'; +import moment from 'moment'; const DEFAULT_PROPS = { content: { @@ -537,6 +538,9 @@ describe('Task card', () => { }); it('renders "Started" with a specific date if status is not AVAILABLE', () => { + const originalFromNow = moment.prototype.fromNow; + moment.prototype.fromNow = jest.fn(() => '4 years ago'); + const { getByTestId } = renderWithRouter( { {} ); const spanElement = screen.getByTestId('started-on'); - expect(spanElement).toHaveTextContent('Started 4 years ago'); // Mocked date from moment + expect(spanElement).toHaveTextContent('Started 4 years ago'); + moment.prototype.fromNow = originalFromNow; }); it('Should show the status of the task', () => { renderWithRouter( diff --git a/src/app/services/api.ts b/src/app/services/api.ts index b8cb919b6..6b55d3c6f 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -28,6 +28,7 @@ export const api = createApi({ 'User_Standup', 'TASK_REQUEST', 'Extension_Requests', + 'Logs', ], /** * This api has endpoints injected in adjacent files, diff --git a/src/app/services/logsApi.ts b/src/app/services/logsApi.ts new file mode 100644 index 000000000..ff9ecf7ef --- /dev/null +++ b/src/app/services/logsApi.ts @@ -0,0 +1,42 @@ +import { api } from './api'; + +export interface LogEntry { + user?: string; + requestId: string; + from: number; + until: number; + type: string; + timestamp: number; + message?: string; +} + +export interface LogsResponse { + message: string; + data: LogEntry[]; + next: string | null; + prev: string | null; +} + +export interface LogsQueryArgs { + username: string; + dev?: boolean; + format?: string; + type?: string; +} + +export const logsApi = api.injectEndpoints({ + endpoints: (build) => ({ + getLogsByUsername: build.query({ + query: ({ + username, + dev = false, + format = 'feed', + type = 'REQUEST_CREATED', + }) => + `/logs?dev=${dev}&format=${format}&type=${type}&username=${username}`, + providesTags: ['Logs'], + }), + }), +}); + +export const { useGetLogsByUsernameQuery } = logsApi; diff --git a/src/components/Calendar/UserSearchField.tsx b/src/components/Calendar/UserSearchField.tsx index 607abe100..56f91ffc3 100644 --- a/src/components/Calendar/UserSearchField.tsx +++ b/src/components/Calendar/UserSearchField.tsx @@ -1,22 +1,37 @@ import { useState, useEffect, ChangeEvent, useRef } from 'react'; import classNames from './UserSearchField.module.scss'; import { useGetAllUsersQuery } from '@/app/services/usersApi'; +import { useGetLogsByUsernameQuery } from '@/app/services/logsApi'; import { logs } from '@/constants/calendar'; import { userDataType } from '@/interfaces/user.type'; import { useOutsideAlerter } from '@/hooks/useOutsideAlerter'; +import { LogEntry } from '@/app/services/logsApi'; +import { LOG_DATA } from '@/utils/userStatusCalendar'; type SearchFieldProps = { - onSearchTextSubmitted: (user: userDataType | undefined, data: any) => void; + onSearchTextSubmitted: ( + user: userDataType | undefined, + data: Array<{ userId: string; data: LOG_DATA[] }>, + oooLogsData?: LogEntry[] + ) => void; loading: boolean; + dev?: boolean; }; -const SearchField = ({ onSearchTextSubmitted, loading }: SearchFieldProps) => { +const SearchField = ({ + onSearchTextSubmitted, + loading, + dev = false, +}: SearchFieldProps) => { const handleOutsideClick = () => { setDisplayList([]); }; const suggestionInputRef = useRef(null); useOutsideAlerter(suggestionInputRef, handleOutsideClick); const [searchText, setSearchText] = useState(''); + const [selectedUser, setSelectedUser] = useState(null); + const lastProcessedUsername = useRef(null); + const onSearchTextChanged = (e: ChangeEvent) => { setSearchText(e.target.value); filterUser(e.target.value); @@ -28,13 +43,24 @@ const SearchField = ({ onSearchTextSubmitted, loading }: SearchFieldProps) => { const user = usersList.find( (user: userDataType) => user.username === searchText ); + setSelectedUser(user || null); + lastProcessedUsername.current = null; onSearchTextSubmitted(user, data); }; const { data: userData, isError, isLoading } = useGetAllUsersQuery(); const [usersList, setUsersList] = useState([]); const [displayList, setDisplayList] = useState([]); - const [data, setData] = useState([]); + const [data, setData] = useState< + Array<{ userId: string; data: LOG_DATA[] }> + >([]); + + const { data: logsData } = useGetLogsByUsernameQuery( + { username: selectedUser?.username || '' }, + { + skip: !dev || !selectedUser?.username, + } + ); useEffect(() => { if (userData?.users) { @@ -42,18 +68,29 @@ const SearchField = ({ onSearchTextSubmitted, loading }: SearchFieldProps) => { const filteredUsers: userDataType[] = users.filter( (user: userDataType) => !user.incompleteUserDetails ); - const logData: any = filteredUsers.map((user: userDataType) => { - const log = logs[Math.floor(Math.random() * 4)]; - return { - data: log, - userId: user.id, - }; - }); + const logData: Array<{ userId: string; data: LOG_DATA[] }> = + filteredUsers.map((user: userDataType) => { + const log = logs[Math.floor(Math.random() * 4)]; + return { + data: log, + userId: user.id, + }; + }); setData(logData); setUsersList(filteredUsers); } }, [isLoading, userData]); + useEffect(() => { + const username = selectedUser?.username; + + if (!dev || !logsData?.data || !username) return; + if (lastProcessedUsername.current === username) return; + + lastProcessedUsername.current = username; + onSearchTextSubmitted(selectedUser, data, logsData.data); + }, [dev, logsData, selectedUser, data]); + const isValidUsername = () => { const usernames = usersList.map((user: userDataType) => user.username); if (usernames.includes(searchText)) { diff --git a/src/constants/url.ts b/src/constants/url.ts index 901cef8f3..59cdb882e 100644 --- a/src/constants/url.ts +++ b/src/constants/url.ts @@ -34,3 +34,4 @@ export const DASHBOARD_URL = 'https://dashboard.realdevsquad.com'; export const USER_MANAGEMENT_URL = `${DASHBOARD_URL}/users/details/`; export const TASK_REQUESTS_DETAILS_URL = `${DASHBOARD_URL}/task-requests/details/`; export const TASK_EXTENSION_REQUEST_URL = `${DASHBOARD_URL}/extension-requests/`; +export const OOO_REQUEST_DETAILS_URL = `${DASHBOARD_URL}/requests/`; diff --git a/src/pages/calendar/index.tsx b/src/pages/calendar/index.tsx index 1136757d9..08f72c20b 100644 --- a/src/pages/calendar/index.tsx +++ b/src/pages/calendar/index.tsx @@ -1,28 +1,93 @@ import { FC, useState } from 'react'; +import { useRouter } from 'next/router'; import Head from '@/components/head'; import Layout from '@/components/Layout'; import Calendar from 'react-calendar'; import 'react-calendar/dist/Calendar.css'; import { SearchField } from '@/components/Calendar/UserSearchField'; -import { processData } from '@/utils/userStatusCalendar'; +import { processData, OOOEntry } from '@/utils/userStatusCalendar'; +import { formatTimestampToDate } from '@/utils/time'; +import { OOO_REQUEST_DETAILS_URL } from '@/constants/url'; import { MONTHS } from '@/constants/calendar'; +import { userDataType } from '@/interfaces/user.type'; + +type ProcessCalendarData = [ + Map, + Map, + Map +]; const UserStatusCalendar: FC = () => { - const [selectedDate, onDateChange] = useState(new Date()); - const [selectedUser, setSelectedUser]: any = useState(null); - const [processedData, setProcessedData] = useState( - processData(selectedUser ? selectedUser.id : null, []) - ); + const router = useRouter(); + const { dev } = router.query; + const isDevMode = dev === 'true'; - const [message, setMessage]: any = useState(null); - const [loading, setLoading]: any = useState(false); + const [selectedDate, onDateChange] = useState(new Date()); + const [selectedUser, setSelectedUser] = useState(null); + const [processedData, setProcessedData] = useState([ + new Map(), + new Map(), + new Map(), + ]); + const [message, setMessage] = useState(null); - const setTileClassName = ({ activeStartDate, date, view }: any) => { + const setTileClassName = ({ date }: { date: Date }) => { if (date.getDay() === 0) return 'sunday'; - return processedData[0] ? processedData[0][date.getTime()] : null; + + if (processedData[2].has(date.getTime())) { + return 'OOO'; + } + + return processedData[0].get(date.getTime()) || null; + }; + + const formatOOOMessage = (oooEntries: OOOEntry[]): JSX.Element => { + return ( + <> + {oooEntries.map((entry, index) => ( +
+ {index > 0 &&
} +
From: {formatTimestampToDate(entry.from)}
+
Until: {formatTimestampToDate(entry.until)}
+
+ Request ID:{' '} + + {entry.requestId} + +
+ {entry.message &&
Message: {entry.message}
} +
+ ))} + + ); + }; + + const formatOOODayMessage = ( + value: Date, + selectedUser: userDataType | null, + oooEntries: OOOEntry[] + ): JSX.Element => { + const dateStr = `${value.getDate()}-${ + MONTHS[value.getMonth()] + }-${value.getFullYear()}`; + const userLine = `${selectedUser?.username} is OOO on ${dateStr}`; + const oooDetails = formatOOOMessage(oooEntries); + return ( +
+
{userLine}
+
{oooDetails}
+
+ ); }; - const handleDayClick = (value: Date, event: any) => { + const handleDayClick = ( + value: Date, + event: React.MouseEvent + ) => { if (value.getDay() === 0) { setMessage( `${value.getDate()}-${ @@ -31,9 +96,21 @@ const UserStatusCalendar: FC = () => { ); return; } + + const oooEntries = processedData[2].get(value.getTime()); + if (oooEntries) { + const message = formatOOODayMessage( + value, + selectedUser, + oooEntries + ); + setMessage(message); + return; + } + if (event.currentTarget.classList.contains('OOO')) { setMessage( - `${selectedUser.username} is OOO on ${value.getDate()}-${ + `${selectedUser?.username} is OOO on ${value.getDate()}-${ MONTHS[value.getMonth()] }-${value.getFullYear()}` ); @@ -41,26 +118,25 @@ const UserStatusCalendar: FC = () => { } if (event.currentTarget.classList.contains('IDLE')) { setMessage( - `${selectedUser.username} is IDLE on ${value.getDate()}-${ + `${selectedUser?.username} is IDLE on ${value.getDate()}-${ MONTHS[value.getMonth()] }-${value.getFullYear()}` ); return; } - if (processedData[1] && processedData[1][value.getTime()]) { + const taskTitle = processedData[1].get(value.getTime()); + if (taskTitle) { setMessage( - `${selectedUser.username} is ACTIVE on ${value.getDate()}-${ + `${selectedUser?.username} is ACTIVE on ${value.getDate()}-${ MONTHS[value.getMonth()] - }-${value.getFullYear()} having task with title - ${ - processedData[1][value.getTime()] - }` + }-${value.getFullYear()} having task with title - ${taskTitle}` ); return; } setMessage( `No user status found for ${ - selectedUser.username + selectedUser?.username } on ${value.getDate()}-${ MONTHS[value.getMonth()] }-${value.getFullYear()}!` @@ -73,19 +149,27 @@ const UserStatusCalendar: FC = () => {
{ - setSelectedUser(user); - setProcessedData( - processData(user ? user.id : null, data) + onSearchTextSubmitted={(user, data, oooLogsData) => { + setSelectedUser(user || null); + const processed = processData( + user ? user.id : null, + data, + oooLogsData ); + setProcessedData(processed); setMessage(null); }} - loading={loading} + loading={false} + dev={isDevMode} /> {selectedUser && (
{ + if (value instanceof Date) { + onDateChange(value); + } + }} className="calendar-div" value={selectedDate} onClickDay={handleDayClick} diff --git a/src/styles/calendar.scss b/src/styles/calendar.scss index 4bbe5b3da..f1de1b870 100644 --- a/src/styles/calendar.scss +++ b/src/styles/calendar.scss @@ -42,6 +42,16 @@ border-radius: 10px; text-align: center; position: relative; + white-space: pre-line; +} + +.messageDiv a { + color: #2563eb; + text-decoration: underline; +} + +.messageDiv a:hover { + color: #1d4ed8; } .messageDiv:before { diff --git a/src/utils/time.ts b/src/utils/time.ts index 0137c8ff7..931114c95 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -23,3 +23,25 @@ export const getDateRelativeToToday = ( ); } }; + +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +export const formatTimestampToDate = (timestamp: number): string => { + const date = new Date(timestamp); + return `${ + MONTHS[date.getMonth()] + } ${date.getDate()}, ${date.getFullYear()}`; +}; diff --git a/src/utils/userStatusCalendar.ts b/src/utils/userStatusCalendar.ts index aa1787a55..719c82f57 100644 --- a/src/utils/userStatusCalendar.ts +++ b/src/utils/userStatusCalendar.ts @@ -1,15 +1,14 @@ -interface LOG_TYPE { - userId: string; - data: []; -} +import { LogEntry } from '@/app/services/logsApi'; -interface LOG_DATA { +export interface LOG_DATA { status: string; startTime: number; endTime: number; - taskTitle: string; + taskTitle?: string; } +export type OOOEntry = LogEntry; + export const getStartOfDay = (date: Date): Date => { if (date instanceof Date && !isNaN(date.getTime())) return new Date(date.getFullYear(), date.getMonth(), date.getDate()); @@ -38,34 +37,81 @@ export const getDatesInRange = (startDate: Date, endDate: Date) => { return dates; }; +export const processOOOLogsData = ( + logsData: LogEntry[] +): [Map, Map] => { + const dictWithOOOEntries = new Map(); + const dictWithTask = new Map(); + + logsData.forEach((logEntry: LogEntry) => { + const dates = getDatesInRange( + new Date(logEntry.from), + new Date(logEntry.until) + ); + + dates.forEach((dateTimestamp) => { + const existingEntries = dictWithOOOEntries.get(dateTimestamp); + if (existingEntries) { + existingEntries.push(logEntry); + } else { + dictWithOOOEntries.set(dateTimestamp, [logEntry]); + } + }); + }); + + return [dictWithOOOEntries, dictWithTask]; +}; + export const processData = ( itemId: string | null, - data: [] -): [object, object] => { + data: Array<{ userId: string; data: LOG_DATA[] }>, + oooLogsData?: LogEntry[] +): [Map, Map, Map] => { if (!itemId) { - return [{}, {}]; + return [new Map(), new Map(), new Map()]; } else { - const log: any = data.find((log: LOG_TYPE) => { - return log.userId === itemId; - }); - if (!log || log.data?.length == 0) return [{}, {}]; - const dictWithStatus: Record = {}; - const dictWithTask: Record = {}; - log.data.forEach((logData: LOG_DATA) => { - const dates = getDatesInRange( - new Date(logData.startTime), - new Date(logData.endTime) - ); - if (logData.status === 'ACTIVE') { - dates.forEach((dateTimestamp) => { - dictWithTask[dateTimestamp] = logData.taskTitle; - }); - } else { - dates.forEach((dateTimestamp) => { - dictWithStatus[dateTimestamp] = logData.status; - }); + const log: { userId: string; data: LOG_DATA[] } | undefined = data.find( + (log: { userId: string; data: LOG_DATA[] }) => { + return log.userId === itemId; } - }); - return [dictWithStatus, dictWithTask]; + ); + + const dictWithStatus = new Map(); + const dictWithTask = new Map(); + const dictWithOOOEntries = new Map(); + + if (log && log.data?.length > 0) { + log.data.forEach((logData: LOG_DATA) => { + const dates = getDatesInRange( + new Date(logData.startTime), + new Date(logData.endTime) + ); + if (logData.status === 'ACTIVE') { + dates.forEach((dateTimestamp) => { + dictWithTask.set( + dateTimestamp, + logData.taskTitle || '' + ); + }); + } else { + dates.forEach((dateTimestamp) => { + dictWithStatus.set(dateTimestamp, logData.status); + }); + } + }); + } + + if (oooLogsData && oooLogsData.length > 0) { + const [oooEntries] = processOOOLogsData(oooLogsData); + oooEntries.forEach((entries, dateTimestamp) => { + dictWithOOOEntries.set(dateTimestamp, entries); + }); + + oooEntries.forEach((_, dateTimestamp) => { + dictWithStatus.set(dateTimestamp, 'OOO'); + }); + } + + return [dictWithStatus, dictWithTask, dictWithOOOEntries]; } };