diff --git a/packages/contracts/src/timesheet-statistics.model.ts b/packages/contracts/src/timesheet-statistics.model.ts index 59bc0b8a0db..6dbc334c2e1 100644 --- a/packages/contracts/src/timesheet-statistics.model.ts +++ b/packages/contracts/src/timesheet-statistics.model.ts @@ -108,6 +108,19 @@ export interface ICountsStatistics { todayDuration: number; } +/** + * Weekly Statistics Activities + */ +export interface IWeeklyStatisticsActivities { + overall: number; + duration: number; +} + +/** + * Today Statistics Activities + */ +export interface ITodayStatisticsActivities extends IWeeklyStatisticsActivities {} + export interface ISelectedDateRange { startDate: Date; endDate: Date; diff --git a/packages/core/src/core/context/request-context.ts b/packages/core/src/core/context/request-context.ts index de7b690be2f..52bc57745d2 100644 --- a/packages/core/src/core/context/request-context.ts +++ b/packages/core/src/core/context/request-context.ts @@ -14,7 +14,7 @@ import { isNotEmpty } from '@gauzy/common'; import { SerializedRequestContext } from './types'; export class RequestContext { - private static logging: boolean = true; + private static logging: boolean = false; protected readonly _id: string; protected readonly _res: Response; private readonly _req: Request; diff --git a/packages/core/src/time-tracking/statistic/statistic.helper.ts b/packages/core/src/time-tracking/statistic/statistic.helper.ts index 727d49228a3..be17f5a8ed6 100644 --- a/packages/core/src/time-tracking/statistic/statistic.helper.ts +++ b/packages/core/src/time-tracking/statistic/statistic.helper.ts @@ -38,14 +38,43 @@ export const getDurationQueryString = (dbType: string, logQueryAlias: string, sl switch (dbType) { case DatabaseTypeEnum.sqlite: case DatabaseTypeEnum.betterSqlite3: - return `COALESCE(ROUND(SUM((julianday(COALESCE("${logQueryAlias}"."stoppedAt", datetime('now'))) - julianday("${logQueryAlias}"."startedAt")) * 86400) / COUNT("${slotQueryAlias}"."id")), 0)`; + return `COALESCE( + ROUND( + SUM( + CASE + WHEN (julianday(COALESCE("${logQueryAlias}"."stoppedAt", datetime('now'))) - + julianday("${logQueryAlias}"."startedAt")) * 86400 >= 0 + THEN (julianday(COALESCE("${logQueryAlias}"."stoppedAt", datetime('now'))) - + julianday("${logQueryAlias}"."startedAt")) * 86400 + ELSE 0 + END + ) / COUNT("${slotQueryAlias}"."id") + ), 0 + )`; case DatabaseTypeEnum.postgres: - return `COALESCE(ROUND(SUM(extract(epoch from (COALESCE("${logQueryAlias}"."stoppedAt", NOW()) - "${logQueryAlias}"."startedAt"))) / COUNT("${slotQueryAlias}"."id")), 0)`; + return `COALESCE( + ROUND( + SUM( + CASE + WHEN extract(epoch from (COALESCE("${logQueryAlias}"."stoppedAt", NOW()) - "${logQueryAlias}"."startedAt")) >= 0 + THEN extract(epoch from (COALESCE("${logQueryAlias}"."stoppedAt", NOW()) - "${logQueryAlias}"."startedAt")) + ELSE 0 + END + ) / COUNT("${slotQueryAlias}"."id") + ), 0 + )`; case DatabaseTypeEnum.mysql: - // Directly return the SQL string for MySQL, as MikroORM allows raw SQL. - return p( - `COALESCE(ROUND(SUM(TIMESTAMPDIFF(SECOND, \`${logQueryAlias}\`.\`startedAt\`, COALESCE(\`${logQueryAlias}\`.\`stoppedAt\`, NOW()))) / COUNT(\`${slotQueryAlias}\`.\`id\`)), 0)` - ); + return p(`COALESCE( + ROUND( + SUM( + CASE + WHEN TIMESTAMPDIFF(SECOND, \`${logQueryAlias}\`.\`startedAt\`, COALESCE(\`${logQueryAlias}\`.\`stoppedAt\`, NOW())) >= 0 + THEN TIMESTAMPDIFF(SECOND, \`${logQueryAlias}\`.\`startedAt\`, COALESCE(\`${logQueryAlias}\`.\`stoppedAt\`, NOW())) + ELSE 0 + END + ) / COUNT(\`${slotQueryAlias}\`.\`id\`) + ), 0 + )`); default: throw new Error(`Unsupported database type: ${dbType}`); } @@ -63,11 +92,43 @@ export const getTotalDurationQueryString = (dbType: string, queryAlias: string): switch (dbType) { case DatabaseTypeEnum.sqlite: case DatabaseTypeEnum.betterSqlite3: - return `COALESCE(ROUND(SUM((julianday(COALESCE("${queryAlias}"."stoppedAt", datetime('now'))) - julianday("${queryAlias}"."startedAt")) * 86400)), 0)`; + return `COALESCE( + ROUND( + SUM( + CASE + WHEN (julianday(COALESCE("${queryAlias}"."stoppedAt", datetime('now'))) - + julianday("${queryAlias}"."startedAt")) * 86400 >= 0 + THEN (julianday(COALESCE("${queryAlias}"."stoppedAt", datetime('now'))) - + julianday("${queryAlias}"."startedAt")) * 86400 + ELSE 0 + END + ) + ), 0 + )`; case DatabaseTypeEnum.postgres: - return `COALESCE(ROUND(SUM(extract(epoch from (COALESCE("${queryAlias}"."stoppedAt", NOW()) - "${queryAlias}"."startedAt")))), 0)`; + return `COALESCE( + ROUND( + SUM( + CASE + WHEN extract(epoch from (COALESCE("${queryAlias}"."stoppedAt", NOW()) - "${queryAlias}"."startedAt")) >= 0 + THEN extract(epoch from (COALESCE("${queryAlias}"."stoppedAt", NOW()) - "${queryAlias}"."startedAt")) + ELSE 0 + END + ) + ), 0 + )`; case DatabaseTypeEnum.mysql: - return `COALESCE(ROUND(SUM(TIMESTAMPDIFF(SECOND, \`${queryAlias}\`.\`startedAt\`, COALESCE(\`${queryAlias}\`.\`stoppedAt\`, NOW())))), 0)`; + return p(`COALESCE( + ROUND( + SUM( + CASE + WHEN TIMESTAMPDIFF(SECOND, \`${queryAlias}\`.\`startedAt\`, COALESCE(\`${queryAlias}\`.\`stoppedAt\`, NOW())) >= 0 + THEN TIMESTAMPDIFF(SECOND, \`${queryAlias}\`.\`startedAt\`, COALESCE(\`${queryAlias}\`.\`stoppedAt\`, NOW())) + ELSE 0 + END + ) + ), 0 + )`); default: throw Error(`Unsupported database type: ${dbType}`); } diff --git a/packages/core/src/time-tracking/statistic/statistic.service.ts b/packages/core/src/time-tracking/statistic/statistic.service.ts index 6d6b41ba09f..866bdccb639 100644 --- a/packages/core/src/time-tracking/statistic/statistic.service.ts +++ b/packages/core/src/time-tracking/statistic/statistic.service.ts @@ -20,7 +20,9 @@ import { IGetManualTimesStatistics, IManualTimesStatistics, TimeLogType, - ITimeLog + ITimeLog, + IWeeklyStatisticsActivities, + ITodayStatisticsActivities } from '@gauzy/contracts'; import { ArraySum, isNotEmpty } from '@gauzy/common'; import { @@ -44,7 +46,7 @@ import { TimeLog, TimeSlot } from './../../core/entities/internal'; import { MultiORMEnum, getDateRangeFormat, getORMType } from './../../core/utils'; import { TypeOrmTimeSlotRepository } from '../../time-tracking/time-slot/repository/type-orm-time-slot.repository'; import { TypeOrmEmployeeRepository } from '../../employee/repository/type-orm-employee.repository'; -import { TypeOrmActivityRepository } from '../activity/repository'; +import { TypeOrmActivityRepository } from '../activity/repository/type-orm-activity.repository'; import { MikroOrmTimeLogRepository, TypeOrmTimeLogRepository } from '../time-log/repository'; // Get the type of the Object-Relational Mapping (ORM) used in the application. @@ -99,233 +101,316 @@ export class StatisticService { } /** - * GET Time Tracking Dashboard Counts Statistics + * Retrieves time tracking dashboard count statistics, including the total number of employees worked, + * projects worked, weekly activities, and today's activities based on the given request. * - * @param request - * @returns + * This function executes multiple asynchronous operations concurrently to fetch the necessary statistics + * and constructs a comprehensive response object with aggregated data. + * + * @param {IGetCountsStatistics} request - The request object containing filters and parameters to fetch + * the counts statistics, such as organizationId, date ranges, employeeIds, projectIds, and other filtering criteria. + * + * @returns {Promise} - Returns a promise that resolves with the counts statistics object, + * containing total employees count, projects count, weekly activity, weekly duration, today's activity, and today's duration. */ async getCounts(request: IGetCountsStatistics): Promise { - const { organizationId, startDate, endDate, todayStart, todayEnd } = request; - let { employeeIds = [], projectIds = [], teamIds = [] } = request; + // Retrieve statistics counts concurrently + const [employeesCount, projectsCount, weekActivities, todayActivities] = await Promise.all([ + this.getEmployeeWorkedCounts(request), + this.getProjectWorkedCounts(request), + this.getWeeklyStatisticsActivities(request), + this.getTodayStatisticsActivities(request) + ]); + + // Construct and return the response object + return { + employeesCount, + projectsCount, + weekActivities: parseFloat(weekActivities.overall.toFixed(2)), + weekDuration: weekActivities.duration, + todayActivities: parseFloat(todayActivities.overall.toFixed(2)), + todayDuration: todayActivities.duration + }; + } - const user = RequestContext.currentUser(); - const tenantId = RequestContext.currentTenantId() || request.tenantId; + /** + * Get average activity and total duration of the work for the week. + * + * @param request - The request object containing filters and parameters + * @returns {Promise} - The weekly activity statistics + */ + async getWeeklyStatisticsActivities(request: IGetCountsStatistics): Promise { + let { + organizationId, + startDate, + endDate, + employeeIds = [], + projectIds = [], + teamIds = [], + activityLevel, + logType, + source, + onlyMe: isOnlyMeSelected // Determine if the request specifies to retrieve data for the current user only + } = request; - const { start, end } = getDateRangeFormat( - moment.utc(startDate || moment().startOf('week')), - moment.utc(endDate || moment().endOf('week')) - ); + // Retrieves the database type from the configuration service. + const dbType = this.configService.dbConnectionOptions.type; + const user = RequestContext.currentUser(); // Retrieve the current user + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; // Retrieve the current tenant ID // Check if the current user has the permission to change the selected employee const hasChangeSelectedEmployeePermission: boolean = RequestContext.hasPermission( PermissionsEnum.CHANGE_SELECTED_EMPLOYEE ); - // Determine if the request specifies to retrieve data for the current user only - const isOnlyMeSelected: boolean = request.onlyMe; - - /** - * Set employeeIds based on user conditions and permissions - */ - if ((user.employeeId && isOnlyMeSelected) || (!hasChangeSelectedEmployeePermission && user.employeeId)) { + // Set employeeIds based on user conditions and permissions + if (user.employeeId && (isOnlyMeSelected || !hasChangeSelectedEmployeePermission)) { employeeIds = [user.employeeId]; } - /** - * GET statistics counts - */ - const employeesCount = await this.getEmployeeWorkedCounts({ - ...request, - employeeIds - }); - const projectsCount = await this.getProjectWorkedCounts({ - ...request, - employeeIds - }); - - // Retrieves the database type from the configuration service. - const dbType = this.configService.dbConnectionOptions.type; - - /* - * Get average activity and total duration of the work for the week. - */ let weekActivities = { overall: 0, duration: 0 }; - const weekQuery = this.typeOrmTimeSlotRepository.createQueryBuilder(); - weekQuery - .innerJoin(`${weekQuery.alias}.timeLogs`, 'timeLogs') - .select(getDurationQueryString(dbType, 'timeLogs', weekQuery.alias), `week_duration`) - .addSelect(p(`COALESCE(SUM("${weekQuery.alias}"."overall"), 0)`), `overall`) - .addSelect(p(`COALESCE(SUM("${weekQuery.alias}"."duration"), 0)`), `duration`) - .addSelect(p(`COUNT("${weekQuery.alias}"."id")`), `time_slot_count`); - - weekQuery - .andWhere(`${weekQuery.alias}.tenantId = :tenantId`, { tenantId }) - .andWhere(`${weekQuery.alias}.organizationId = :organizationId`, { organizationId }) - .andWhere(`timeLogs.tenantId = :tenantId`, { tenantId }) - .andWhere(`timeLogs.organizationId = :organizationId`, { organizationId }); - - weekQuery - .andWhere(p(`"${weekQuery.alias}"."startedAt" BETWEEN :startDate AND :endDate`), { + // Define the start and end dates + const { start, end } = getDateRangeFormat( + moment.utc(startDate || moment().startOf('week')), + moment.utc(endDate || moment().endOf('week')) + ); + + // Create a query builder for the TimeSlot entity + const query = this.typeOrmTimeSlotRepository.createQueryBuilder(); + query + .innerJoin(`${query.alias}.timeLogs`, 'time_log') + .select([ + getDurationQueryString(dbType, 'time_log', query.alias) + ' AS week_duration', + p(`COALESCE(SUM("${query.alias}"."overall"), 0)`) + ' AS overall', + p(`COALESCE(SUM("${query.alias}"."duration"), 0)`) + ' AS duration', + p(`COUNT("${query.alias}"."id")`) + ' AS time_slot_count' + ]); + + // Base where conditions + query + .where(`${query.alias}.tenantId = :tenantId`, { tenantId }) + .andWhere(`${query.alias}.organizationId = :organizationId`, { organizationId }) + .andWhere(`time_log.tenantId = :tenantId`, { tenantId }) + .andWhere(`time_log.organizationId = :organizationId`, { organizationId }); + + query + .andWhere(`${query.alias}.startedAt BETWEEN :startDate AND :endDate`, { startDate: start, endDate: end }) - .andWhere(p(`"timeLogs"."startedAt" BETWEEN :startDate AND :endDate`), { startDate: start, endDate: end }); + .andWhere(`time_log.startedAt BETWEEN :startDate AND :endDate`, { startDate: start, endDate: end }) + .andWhere(`time_log.stoppedAt >= time_log.startedAt`); + // Applying optional filters conditionally to avoid unnecessary execution if (isNotEmpty(employeeIds)) { - weekQuery.andWhere(p(`"${weekQuery.alias}"."employeeId" IN (:...employeeIds)`), { employeeIds }); - weekQuery.andWhere(p(`"timeLogs"."employeeId" IN (:...employeeIds)`), { employeeIds }); + query.andWhere(`${query.alias}.employeeId IN (:...employeeIds)`, { employeeIds }); + query.andWhere(`time_log.employeeId IN (:...employeeIds)`, { employeeIds }); } + // Filter by project if (isNotEmpty(projectIds)) { - weekQuery.andWhere(p(`"timeLogs"."projectId" IN (:...projectIds)`), { projectIds }); + query.andWhere(`time_log.projectId IN (:...projectIds)`, { projectIds }); } - if (isNotEmpty(request.activityLevel)) { - /** - * Activity Level should be 0-100% - * So, we have convert it into 10 minutes TimeSlot by multiply by 6 - */ - const { activityLevel } = request; - const startLevel = activityLevel.start * 6; - const endLevel = activityLevel.end * 6; + // Filter by team + if (isNotEmpty(teamIds)) { + query.andWhere(`time_log.organizationTeamId IN (:...teamIds)`, { teamIds }); + } + + // Filter by activity level + if (isNotEmpty(activityLevel)) { + const startLevel = activityLevel.start * 6; // Start level for activity level in seconds + const endLevel = activityLevel.end * 6; // End level for activity level in seconds - weekQuery.andWhere(p(`"${weekQuery.alias}"."overall" BETWEEN :startLevel AND :endLevel`), { + query.andWhere(`${query.alias}.overall BETWEEN :startLevel AND :endLevel`, { startLevel, endLevel }); } - if (isNotEmpty(request.logType)) { - const { logType } = request; - weekQuery.andWhere(p(`"timeLogs"."logType" IN (:...logType)`), { logType }); + // Filter by log type + if (isNotEmpty(logType)) { + query.andWhere(`time_log.logType IN (:...logType)`, { logType }); } - if (isNotEmpty(request.source)) { - const { source } = request; - weekQuery.andWhere(p(`"timeLogs"."source" IN (:...source)`), { source }); + // Filter by source + if (isNotEmpty(source)) { + query.andWhere(`time_log.source IN (:...source)`, { source }); } - if (isNotEmpty(teamIds)) { - weekQuery.andWhere(p(`"timeLogs"."organizationTeamId" IN (:...teamIds)`), { teamIds }); - } + // Group by time_log.id to get the total duration and overall for each time slot + const weekTimeStatistics = await query.groupBy(p(`"time_log"."id"`)).getRawMany(); + console.log('weekly time statistics activity', JSON.stringify(weekTimeStatistics)); - weekQuery.groupBy(p(`"timeLogs"."id"`)); - const weekTimeStatistics = await weekQuery.getRawMany(); + // Initialize variables to accumulate values + let totalWeekDuration = 0; + let totalOverall = 0; + let totalDuration = 0; - const weekDuration = reduce(pluck(weekTimeStatistics, 'week_duration'), ArraySum, 0); - const weekPercentage = - (reduce(pluck(weekTimeStatistics, 'overall'), ArraySum, 0) * 100) / - reduce(pluck(weekTimeStatistics, 'duration'), ArraySum, 0); + // Iterate over the weekTimeStatistics array once to calculate all values + for (const stat of weekTimeStatistics) { + totalWeekDuration += Number(stat.week_duration) || 0; + totalOverall += Number(stat.overall) || 0; + totalDuration += Number(stat.duration) || 0; + } - weekActivities['duration'] = weekDuration; + // Calculate the week percentage, avoiding division by zero + const weekPercentage = totalDuration > 0 ? (totalOverall * 100) / totalDuration : 0; + + // Assign the calculated values to weekActivities + weekActivities['duration'] = totalWeekDuration; weekActivities['overall'] = weekPercentage; - /* - * Get average activity and total duration of the work for today. + return weekActivities; + } + + /** + * Get average activity and total duration of the work for today. + * + * @param request - The request object containing filters and parameters + * @returns {Promise} - Today's activity statistics + */ + async getTodayStatisticsActivities(request: IGetCountsStatistics): Promise { + // Destructure the necessary properties from the request with default values + let { + organizationId, + todayStart, + todayEnd, + employeeIds = [], + projectIds = [], + teamIds = [], + activityLevel, + onlyMe: isOnlyMeSelected, // Determine if the request specifies to retrieve data for the current user only + logType, + source + } = request || {}; + + // Retrieves the database type from the configuration service. + const dbType = this.configService.dbConnectionOptions.type; + const user = RequestContext.currentUser(); // Retrieve the current user + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; // Retrieve the current tenant ID + + // Check if the current user has the permission to change the selected employee + const hasChangeSelectedEmployeePermission: boolean = RequestContext.hasPermission( + PermissionsEnum.CHANGE_SELECTED_EMPLOYEE + ); + + /** + * Set employeeIds based on user conditions and permissions */ + if (user.employeeId && (isOnlyMeSelected || !hasChangeSelectedEmployeePermission)) { + employeeIds = [user.employeeId]; + } + + // Get average activity and total duration of the work for today. let todayActivities = { overall: 0, duration: 0 }; + // Get date range for today const { start: startToday, end: endToday } = getDateRangeFormat( moment.utc(todayStart || moment().startOf('day')), moment.utc(todayEnd || moment().endOf('day')) ); - const todayQuery = this.typeOrmTimeSlotRepository.createQueryBuilder(); - todayQuery - .innerJoin(`${todayQuery.alias}.timeLogs`, 'timeLogs') - .select(getDurationQueryString(dbType, 'timeLogs', todayQuery.alias), `today_duration`) - .addSelect(p(`COALESCE(SUM("${todayQuery.alias}"."overall"), 0)`), `overall`) - .addSelect(p(`COALESCE(SUM("${todayQuery.alias}"."duration"), 0)`), `duration`) - .addSelect(p(`COUNT("${todayQuery.alias}"."id")`), `time_slot_count`); - - todayQuery - .andWhere(`${todayQuery.alias}.tenantId = :tenantId`, { tenantId }) - .andWhere(`${todayQuery.alias}.organizationId = :organizationId`, { organizationId }) - .andWhere(`timeLogs.tenantId = :tenantId`, { tenantId }) - .andWhere(`timeLogs.organizationId = :organizationId`, { organizationId }); - - todayQuery - .andWhere(p(`"timeLogs"."startedAt" BETWEEN :startDate AND :endDate`), { + // Create a query builder for the TimeSlot entity + const query = this.typeOrmTimeSlotRepository.createQueryBuilder(); + + // Define the base select statements and joins + query + .innerJoin(`${query.alias}.timeLogs`, 'time_log') + .select([ + getDurationQueryString(dbType, 'time_log', query.alias) + ' AS today_duration', + p(`COALESCE(SUM("${query.alias}"."overall"), 0)`) + ' AS overall', + p(`COALESCE(SUM("${query.alias}"."duration"), 0)`) + ' AS duration', + p(`COUNT("${query.alias}"."id")`) + ' AS time_slot_count' + ]); + + // Base where conditions + query + .andWhere(`${query.alias}.tenantId = :tenantId`, { tenantId }) + .andWhere(`${query.alias}.organizationId = :organizationId`, { organizationId }) + .andWhere(`time_log.tenantId = :tenantId`, { tenantId }) + .andWhere(`time_log.organizationId = :organizationId`, { organizationId }); + + query + .andWhere(p(`"${query.alias}"."startedAt" BETWEEN :startDate AND :endDate`), { startDate: startToday, endDate: endToday }) - .andWhere(p(`"${todayQuery.alias}"."startedAt" BETWEEN :startDate AND :endDate`), { + .andWhere(p(`"time_log"."startedAt" BETWEEN :startDate AND :endDate`), { startDate: startToday, endDate: endToday - }); + }) + .andWhere(`time_log.stoppedAt >= time_log.startedAt`); + // Optional filters if (isNotEmpty(employeeIds)) { - todayQuery.andWhere(p(`"timeLogs"."employeeId" IN (:...employeeIds)`), { employeeIds }); - todayQuery.andWhere(p(`"${todayQuery.alias}"."employeeId" IN (:...employeeIds)`), { employeeIds }); + query + .andWhere(p(`"${query.alias}"."employeeId" IN (:...employeeIds)`), { employeeIds }) + .andWhere(p(`"time_log"."employeeId" IN (:...employeeIds)`), { employeeIds }); + } + + if (isNotEmpty(teamIds)) { + query.andWhere(p(`"time_log"."organizationTeamId" IN (:...teamIds)`), { teamIds }); } if (isNotEmpty(projectIds)) { - todayQuery.andWhere(p(`"timeLogs"."projectId" IN (:...projectIds)`), { projectIds }); + query.andWhere(p(`"time_log"."projectId" IN (:...projectIds)`), { projectIds }); } - if (isNotEmpty(request.activityLevel)) { + if (isNotEmpty(activityLevel)) { /** * Activity Level should be 0-100% - * So, we have convert it into 10 minutes TimeSlot by multiply by 6 + * So, we have to convert it into a 10-minute TimeSlot by multiplying by 6 */ - const { activityLevel } = request; const startLevel = activityLevel.start * 6; const endLevel = activityLevel.end * 6; - todayQuery.andWhere(p(`"${todayQuery.alias}"."overall" BETWEEN :startLevel AND :endLevel`), { + query.andWhere(p(`"${query.alias}"."overall" BETWEEN :startLevel AND :endLevel`), { startLevel, endLevel }); } - if (isNotEmpty(request.logType)) { - const { logType } = request; - todayQuery.andWhere(p(`"timeLogs"."logType" IN (:...logType)`), { logType }); + if (isNotEmpty(logType)) { + query.andWhere(p(`"time_log"."logType" IN (:...logType)`), { logType }); } - if (isNotEmpty(request.source)) { - const { source } = request; - todayQuery.andWhere(p(`"timeLogs"."source" IN (:...source)`), { source }); + if (isNotEmpty(source)) { + query.andWhere(p(`"time_log"."source" IN (:...source)`), { source }); } - if (isNotEmpty(teamIds)) { - todayQuery.andWhere(p(`"timeLogs"."organizationTeamId" IN (:...teamIds)`), { teamIds }); - } + const todayTimeStatistics = await query.groupBy(p(`"time_log"."id"`)).getRawMany(); + console.log('today time statistics activity', JSON.stringify(todayTimeStatistics)); - todayQuery.groupBy(p(`"timeLogs"."id"`)); - const todayTimeStatistics = await todayQuery.getRawMany(); + // Initialize variables to accumulate values + let totalTodayDuration = 0; + let totalOverall = 0; + let totalDuration = 0; + + // Iterate over the todayTimeStatistics array once to calculate all values + for (const stat of todayTimeStatistics) { + totalTodayDuration += Number(stat.today_duration) || 0; + totalOverall += Number(stat.overall) || 0; + totalDuration += Number(stat.duration) || 0; + } - const todayDuration = reduce(pluck(todayTimeStatistics, 'today_duration'), ArraySum, 0); - const todayPercentage = - (reduce(pluck(todayTimeStatistics, 'overall'), ArraySum, 0) * 100) / - reduce(pluck(todayTimeStatistics, 'duration'), ArraySum, 0); + // Calculate today's percentage, avoiding division by zero + const todayPercentage = totalDuration > 0 ? (totalOverall * 100) / totalDuration : 0; - todayActivities['duration'] = todayDuration; + // Assign the calculated values to todayActivities + todayActivities['duration'] = totalTodayDuration; todayActivities['overall'] = todayPercentage; - return { - employeesCount, - projectsCount, - weekActivities: parseFloat(parseFloat(weekActivities.overall + '').toFixed(2)), - weekDuration: weekActivities.duration, - todayActivities: parseFloat(parseFloat(todayActivities.overall + '').toFixed(2)), - todayDuration: todayActivities.duration - }; + return todayActivities; } - /** - * GET Time Tracking Dashboard Worked Members Statistics - * - * @param request - * @returns - */ /** * GET Time Tracking Dashboard Worked Members Statistics * @@ -333,12 +418,24 @@ export class StatisticService { * @returns */ async getMembers(request: IGetMembersStatistics): Promise { - const { organizationId, startDate, endDate, todayStart, todayEnd } = request; - let { employeeIds = [], projectIds = [], teamIds = [] } = request; + // Destructure properties from the request with default values where necessary + let { + organizationId, + startDate, + endDate, + todayStart, + todayEnd, + employeeIds = [], + projectIds = [], + teamIds = [] + } = request || {}; - const user = RequestContext.currentUser(); - const tenantId = RequestContext.currentTenantId() || request.tenantId; + // Retrieves the database type from the configuration service. + const dbType = this.configService.dbConnectionOptions.type; + const user = RequestContext.currentUser(); // Retrieve the current user + const tenantId = RequestContext.currentTenantId() || request.tenantId; // Retrieve the current tenant ID + // Get the start and end date for the weekly statistics const { start: weeklyStart, end: weeklyEnd } = getDateRangeFormat( moment.utc(startDate || moment().startOf('week')), moment.utc(endDate || moment().endOf('week')) @@ -349,12 +446,7 @@ export class StatisticService { PermissionsEnum.CHANGE_SELECTED_EMPLOYEE ); - // Retrieves the database type from the configuration service. - const dbType = this.configService.dbConnectionOptions.type; - - /** - * Set employeeIds based on user conditions and permissions - */ + // Set employeeIds based on user conditions and permissions if (user.employeeId || (!hasChangeSelectedEmployeePermission && user.employeeId)) { employeeIds = [user.employeeId]; } @@ -1826,46 +1918,48 @@ export class StatisticService { } /** - * Get employees count who worked in this week. + * Get the count of employees who worked this week. * * @param request - * @returns + * @returns The count of unique employees */ private async getEmployeeWorkedCounts(request: IGetCountsStatistics) { const query = this.typeOrmTimeLogRepository.createQueryBuilder('time_log'); - query.select(p(`"${query.alias}"."employeeId"`), 'employeeId'); - query.innerJoin(`${query.alias}.employee`, 'employee'); - query.innerJoin(`${query.alias}.timeSlots`, 'time_slot'); - query.andWhere( - new Brackets((where: WhereExpressionBuilder) => { - this.getFilterQuery(query, where, request); - }) - ); - query.groupBy(p(`"${query.alias}"."employeeId"`)); - const employees = await query.getRawMany(); - return employees.length; + query + .select('COUNT(DISTINCT time_log.employeeId)', 'count') + .innerJoin('time_log.employee', 'employee') + .innerJoin('time_log.timeSlots', 'time_slot') + .andWhere( + new Brackets((where: WhereExpressionBuilder) => { + this.getFilterQuery(query, where, request); + }) + ); + const result = await query.getRawOne(); + const count = parseInt(result.count, 10); + return count; } /** - * Get projects count who worked in this week. + * Get the count of projects worked on this week. * * @param request - * @returns + * @returns The count of unique projects */ private async getProjectWorkedCounts(request: IGetCountsStatistics) { const query = this.typeOrmTimeLogRepository.createQueryBuilder('time_log'); - query.select(p(`"${query.alias}"."projectId"`), 'projectId'); - query.innerJoin(`${query.alias}.employee`, 'employee'); - query.innerJoin(`${query.alias}.project`, 'project'); - query.innerJoin(`${query.alias}.timeSlots`, 'time_slot'); - query.andWhere( - new Brackets((where: WhereExpressionBuilder) => { - this.getFilterQuery(query, where, request); - }) - ); - query.groupBy(p(`"${query.alias}"."projectId"`)); - const projects = await query.getRawMany(); - return projects.length; + query + .select('COUNT(DISTINCT time_log.projectId)', 'count') + .innerJoin('time_log.employee', 'employee') + .innerJoin('time_log.project', 'project') + .innerJoin('time_log.timeSlots', 'time_slot') + .andWhere( + new Brackets((where: WhereExpressionBuilder) => { + this.getFilterQuery(query, where, request); + }) + ); + const result = await query.getRawOne(); + const count = parseInt(result.count, 10); + return count; } /** @@ -1881,9 +1975,33 @@ export class StatisticService { qb: WhereExpressionBuilder, request: IGetCountsStatistics ): WhereExpressionBuilder { - const { organizationId, startDate, endDate, employeeIds = [], projectIds = [], teamIds = [] } = request; - const tenantId = RequestContext.currentTenantId() || request.tenantId; + let { + organizationId, + startDate, + endDate, + employeeIds = [], + projectIds = [], + teamIds = [], + activityLevel, + logType, + source, + onlyMe: isOnlyMeSelected // Determine if the request specifies to retrieve data for the current user only + } = request; + + const user = RequestContext.currentUser(); // Retrieve the current user + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; // Retrieve the current tenant ID + // Check if the current user has the permission to change the selected employee + const hasChangeSelectedEmployeePermission: boolean = RequestContext.hasPermission( + PermissionsEnum.CHANGE_SELECTED_EMPLOYEE + ); + + // Set employeeIds based on user conditions and permissions + if (user.employeeId && (isOnlyMeSelected || !hasChangeSelectedEmployeePermission)) { + employeeIds = [user.employeeId]; + } + + // Use consistent date range formatting const { start, end } = getDateRangeFormat( moment.utc(startDate || moment().startOf('week')), moment.utc(endDate || moment().endOf('week')) @@ -1894,33 +2012,38 @@ export class StatisticService { qb.andWhere(`${query.alias}.startedAt BETWEEN :startDate AND :endDate`, { startDate: start, endDate: end }); qb.andWhere(`time_slot.startedAt BETWEEN :startDate AND :endDate`, { startDate: start, endDate: end }); - if (isNotEmpty(request.activityLevel)) { - const { start: startLevel, end: endLevel } = request.activityLevel; + // Apply activity level filter only if provided + if (isNotEmpty(activityLevel)) { + const startLevel = activityLevel.start * 6; // Start level for activity level in seconds + const endLevel = activityLevel.end * 6; // End level for activity level in seconds + qb.andWhere(`time_slot.overall BETWEEN :startLevel AND :endLevel`, { - startLevel: startLevel * 6, - endLevel: endLevel * 6 + startLevel, + endLevel }); } - if (isNotEmpty(request.logType)) { - qb.andWhere(`${query.alias}.logType IN (:...logType)`, { logType: request.logType }); + // Apply log type filter if present + if (isNotEmpty(logType)) { + qb.andWhere(`${query.alias}.logType IN (:...logType)`, { logType }); } - if (isNotEmpty(request.source)) { - qb.andWhere(`${query.alias}.source IN (:...source)`, { source: request.source }); + // Apply source filter if present + if (isNotEmpty(source)) { + qb.andWhere(`${query.alias}.source IN (:...source)`, { source }); } + // Apply employee filter, optimizing joins if (isNotEmpty(employeeIds)) { - qb.andWhere(`${query.alias}.employeeId IN (:...employeeIds)`, { employeeIds }).andWhere( - `time_slot.employeeId IN (:...employeeIds)`, - { employeeIds } - ); + qb.andWhere(`${query.alias}.employeeId IN (:...employeeIds)`, { employeeIds }); } + // Apply project filter if (isNotEmpty(projectIds)) { qb.andWhere(`${query.alias}.projectId IN (:...projectIds)`, { projectIds }); } + // Apply team filter if (isNotEmpty(teamIds)) { qb.andWhere(`${query.alias}.organizationTeamId IN (:...teamIds)`, { teamIds }); } diff --git a/packages/core/src/time-tracking/timer/timer.service.ts b/packages/core/src/time-tracking/timer/timer.service.ts index 22cd9275d6d..f0ec743a930 100644 --- a/packages/core/src/time-tracking/timer/timer.service.ts +++ b/packages/core/src/time-tracking/timer/timer.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ForbiddenException, NotAcceptableExcepti import { CommandBus } from '@nestjs/cqrs'; import { IsNull, Between, Not, In } from 'typeorm'; import * as moment from 'moment'; +import * as chalk from 'chalk'; import { TimeLogType, ITimerStatus, @@ -199,9 +200,6 @@ export class TimerService { JSON.stringify(request) ); - // Retrieve the tenant ID from the current context or the provided one in the request - const tenantId = RequestContext.currentTenantId() || request.tenantId; - // Destructure the necessary parameters from the request const { source, @@ -215,9 +213,12 @@ export class TimerService { version } = request; + // Retrieve the tenant ID from the current context or the provided one in the request + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; + // Determine the start date and time in UTC const startedAt = moment.utc(request.startedAt ?? moment.utc()).toDate(); - console.log('timer start date', startedAt); + console.log(chalk.green('new timer started at:'), startedAt); // Retrieve the employee information const employee = await this.findEmployee(); @@ -230,18 +231,8 @@ export class TimerService { // Get the employee ID const { id: employeeId, organizationId } = employee; - try { - // Retrieve any existing running logs for the employee - const logs = await this.getLastRunningLogs(); - console.log('Last Running Logs Count:', logs.length); - - // If there are existing running logs, stop them before starting a new one - if (logs.length > 0) { - await this.stopPreviousRunningTimers(employeeId, organizationId, tenantId); - } - } catch (error) { - console.error('Error while getting last running logs', error); - } + // Stop any previous running timers + await this.stopPreviousRunningTimers(employeeId, organizationId, tenantId); // Create a new time log entry using the command bus const timeLog = await this._commandBus.execute( @@ -271,6 +262,8 @@ export class TimerService { isTrackingTime: true }); + console.log(chalk.green(`last created time log: ${JSON.stringify(timeLog)}`)); + // Return the newly created time log entry return timeLog; } @@ -283,7 +276,7 @@ export class TimerService { */ async stopTimer(request: ITimerToggleInput): Promise { console.log( - `-------------Stop Timer Request (${moment.utc(request.startedAt).toDate()})-------------`, + `-------------Stop Timer Request (${moment.utc(request.stoppedAt).toDate()})-------------`, JSON.stringify(request) ); @@ -292,66 +285,65 @@ export class TimerService { // Fetch the employee details const employee = await this.findEmployee(); - - // Retrieve the employee ID and organization ID - const { id: employeeId, organizationId } = employee; - // Retrieve tenant ID - const tenantId = RequestContext.currentTenantId() || employee.tenantId || request.tenantId; - // Check if time tracking is enabled for the employee if (!employee.isTrackingEnabled) { throw new ForbiddenException('The time tracking functionality has been disabled for you.'); } - // Determine whether to include time slots in the result - const includeTimeSlots = true; + // Retrieve tenant ID + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; + // Retrieve the employee ID and organization ID + const { id: employeeId, organizationId } = employee; // Retrieve the last running log + const includeTimeSlots = true; let lastLog = await this.getLastRunningLog(includeTimeSlots); - // If no running log is found throw an NotAcceptableException with a message + // If no running log is found, throw a NotAcceptableException if (!lastLog) { - console.log(`No running log found. Can't stop timer because it was already stopped.`); + console.log(chalk.yellow(`No running log found. Can't stop timer because it was already stopped.`)); throw new NotAcceptableException(`No running log found. Can't stop timer because it was already stopped.`); } - // Retrieve stoppedAt date or use current date if not provided - let stoppedAt = await this.calculateStoppedAt(request, lastLog); + // Calculate stoppedAt date or use current date if not provided + const stoppedAt = await this.calculateStoppedAt(request, lastLog); + console.log(chalk.blue(`last stopped at: ${stoppedAt}`)); - // Update the time log entry to mark it as stopped - lastLog = await this._commandBus.execute( - new TimeLogUpdateCommand( - { - stoppedAt, - isRunning: false - }, - lastLog.id, - request.manualTimeSlot - ) - ); + // Log the case where stoppedAt is less than startedAt + if (stoppedAt < lastLog.startedAt) { + console.log( + chalk.yellow( + `stoppedAt (${stoppedAt}) is less than startedAt (${lastLog.startedAt}), skipping stoppedAt update.` + ) + ); + } - try { - // Retrieve any existing running logs for the employee - const logs = await this.getLastRunningLogs(); - console.log('Last Running Logs Count:', logs.length); + // Construct the update payload, conditionally excluding stoppedAt if it shouldn't be updated + const partialTimeLog: Partial = { + isRunning: false, + ...(stoppedAt >= lastLog.startedAt && { stoppedAt }) // Only include stoppedAt if it's valid + }; - // If there are existing running logs, stop them before starting a new one - if (logs.length > 0) { - await this.stopPreviousRunningTimers(employeeId, organizationId, tenantId); - } - } catch (error) { - console.error('Error while getting last running logs', error); - } + console.log(chalk.blue(`partial time log: ${JSON.stringify(partialTimeLog)}`)); + + // Execute the command to update the time log entry + lastLog = await this._commandBus.execute( + new TimeLogUpdateCommand(partialTimeLog, lastLog.id, request.manualTimeSlot) + ); - // Update the employee's tracking status + // Update the employee's tracking status to reflect they are now tracking time await this._employeeService.update(employeeId, { isOnline: false, // Employee status (Online/Offline) isTrackingTime: false // Employee time tracking status }); + // Stop previous running timers + await this.stopPreviousRunningTimers(employeeId, organizationId, tenantId); + // Handle conflicting time logs await this.handleConflictingTimeLogs(lastLog, tenantId, organizationId); + // Return the last log return lastLog; } @@ -364,6 +356,9 @@ export class TimerService { */ private async handleConflictingTimeLogs(lastLog: ITimeLog, tenantId: ID, organizationId: ID): Promise { try { + // Validate the date range and check if the timer is running + validateDateRange(lastLog.startedAt, lastLog.stoppedAt); + // Retrieve conflicting time logs const conflicts = await this._commandBus.execute( new IGetConflictTimeLogCommand({ @@ -402,10 +397,23 @@ export class TimerService { ); } } catch (error) { - console.error('Error while handling conflicts in time logs:', error); + console.warn('Error while handling conflicts in time logs:', error?.message); } } + /** + * Calculates the stoppedAt time for the current time log based on the request and the last running time log. + * It adjusts the stoppedAt time based on various conditions, such as the time log source (e.g., DESKTOP) and time slots. + * + * - If the source is DESKTOP and the last time slot was created more than 10 minutes ago, + * the stoppedAt time is adjusted based on the last time slot's duration. + * - If no time slots exist and the last log's startedAt time exceeds 10 minutes from the current time, + * the stoppedAt time is adjusted by 10 seconds from the startedAt time. + * + * @param {ITimerToggleInput} request - The input data for stopping the timer, including stoppedAt and source. + * @param {ITimeLog} lastLog - The last running time log, which may include time slots for more detailed tracking. + * @returns {Promise} - A promise that resolves to the calculated stoppedAt date, adjusted as necessary. + */ async calculateStoppedAt(request: ITimerToggleInput, lastLog: ITimeLog): Promise { // Retrieve stoppedAt date or default to the current date if not provided let stoppedAt = moment.utc(request.stoppedAt ?? moment.utc()).toDate(); @@ -555,11 +563,20 @@ export class TimerService { */ async stopPreviousRunningTimers(employeeId: ID, organizationId: ID, tenantId: ID): Promise { try { - // Execute the ScheduleTimeLogEntriesCommand to stop all previous running timers - await this._commandBus.execute(new ScheduleTimeLogEntriesCommand(employeeId, organizationId, tenantId)); + console.log(chalk.green('Start previous running timers...')); + // Retrieve any existing running logs for the employee + const logs = await this.getLastRunningLogs(); + console.log(chalk.blue('Last Running Logs Count:'), logs.length); + + // If there are existing running logs, stop them before starting a new one + if (logs.length > 0) { + // Execute the ScheduleTimeLogEntriesCommand to stop all previous running timers + await this._commandBus.execute(new ScheduleTimeLogEntriesCommand(employeeId, organizationId, tenantId)); + } + console.log(chalk.green('Stop previous running timers...')); } catch (error) { // Log the error or handle it appropriately - console.log('Failed to stop previous running timers:', error); + console.log(chalk.red('Failed to stop previous running timers:'), error?.message); } }