From 69b2fe1a90ab035a724294d29930f4af148fb26e Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 24 Jul 2025 07:50:01 +0530 Subject: [PATCH 1/4] feat(reporting): implement timezone support in reporting allocation and related components - Added timezone handling in the getMemberTimeSheets method to ensure accurate date calculations based on user timezone. - Created ReportingControllerBaseWithTimezone to centralize timezone-related logic for reporting. - Introduced a migration to add a timezone column to the users table for better user experience. - Updated frontend API services and hooks to include user's timezone in requests. - Enhanced members time reports page to display time logs in the user's local timezone. --- ...reporting-allocation-controller-updated.ts | 176 ++++++++++++++++++ ...reporting-controller-base-with-timezone.ts | 114 ++++++++++++ .../src/migrations/add_user_timezone.sql | 8 + ...reporting.timesheet.api.service.updated.ts | 92 +++++++++ .../src/hooks/useUserTimezone.ts | 70 +++++++ .../members-time-reports-updated.tsx | 67 +++++++ 6 files changed, 527 insertions(+) create mode 100644 worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts create mode 100644 worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts create mode 100644 worklenz-backend/src/migrations/add_user_timezone.sql create mode 100644 worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.updated.ts create mode 100644 worklenz-frontend/src/hooks/useUserTimezone.ts create mode 100644 worklenz-frontend/src/pages/reporting/timeReports/members-time-reports-updated.tsx diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts new file mode 100644 index 00000000..d416d0b6 --- /dev/null +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts @@ -0,0 +1,176 @@ +// Example of updated getMemberTimeSheets method with timezone support +// This shows the key changes needed to handle timezones properly + +import moment from "moment-timezone"; +import db from "../../config/db"; +import { IWorkLenzRequest } from "../../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../../interfaces/worklenz-response"; +import { ServerResponse } from "../../models/server-response"; +import { DATE_RANGES } from "../../shared/constants"; + +export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const archived = req.query.archived === "true"; + const teams = (req.body.teams || []) as string[]; + const teamIds = teams.map(id => `'${id}'`).join(","); + const projects = (req.body.projects || []) as string[]; + const projectIds = projects.map(p => `'${p}'`).join(","); + const billable = req.body.billable; + + // Get user timezone from request or database + const userTimezone = req.body.timezone || await getUserTimezone(req.user?.id || ''); + + if (!teamIds || !projectIds.length) + return res.status(200).send(new ServerResponse(true, { users: [], projects: [] })); + + const { duration, date_range } = req.body; + + // Calculate date range with timezone support + let startDate: moment.Moment; + let endDate: moment.Moment; + + if (date_range && date_range.length === 2) { + // Convert user's local dates to their timezone's start/end of day + startDate = moment.tz(date_range[0], userTimezone).startOf('day'); + endDate = moment.tz(date_range[1], userTimezone).endOf('day'); + } else if (duration === DATE_RANGES.ALL_TIME) { + const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`; + const minDateResult = await db.query(minDateQuery, []); + const minDate = minDateResult.rows[0]?.min_date; + startDate = minDate ? moment.tz(minDate, userTimezone) : moment.tz('2000-01-01', userTimezone); + endDate = moment.tz(userTimezone); + } else { + // Calculate ranges based on user's timezone + const now = moment.tz(userTimezone); + + switch (duration) { + case DATE_RANGES.YESTERDAY: + startDate = now.clone().subtract(1, "day").startOf('day'); + endDate = now.clone().subtract(1, "day").endOf('day'); + break; + case DATE_RANGES.LAST_WEEK: + startDate = now.clone().subtract(1, "week").startOf("isoWeek"); + endDate = now.clone().subtract(1, "week").endOf("isoWeek"); + break; + case DATE_RANGES.LAST_MONTH: + startDate = now.clone().subtract(1, "month").startOf("month"); + endDate = now.clone().subtract(1, "month").endOf("month"); + break; + case DATE_RANGES.LAST_QUARTER: + startDate = now.clone().subtract(3, "months").startOf("day"); + endDate = now.clone().endOf("day"); + break; + default: + startDate = now.clone().startOf("day"); + endDate = now.clone().endOf("day"); + } + } + + // Convert to UTC for database queries + const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss"); + const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss"); + + // Calculate working days in user's timezone + const totalDays = endDate.diff(startDate, "days") + 1; + let workingDays = 0; + + const current = startDate.clone(); + while (current.isSameOrBefore(endDate, 'day')) { + if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) { + workingDays++; + } + current.add(1, 'day'); + } + + // Updated SQL query with proper timezone handling + const billableQuery = buildBillableQuery(billable); + const archivedClause = archived ? "" : `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${req.user?.id}')`; + + const q = ` + WITH project_hours AS ( + SELECT + id, + COALESCE(hours_per_day, 8) as hours_per_day + FROM projects + WHERE id IN (${projectIds}) + ), + total_working_hours AS ( + SELECT + SUM(hours_per_day) * ${workingDays} as total_hours + FROM project_hours + ) + SELECT + u.id, + u.email, + tm.name, + tm.color_code, + COALESCE(SUM(twl.time_spent), 0) as logged_time, + COALESCE(SUM(twl.time_spent), 0) / 3600.0 as value, + (SELECT total_hours FROM total_working_hours) as total_working_hours, + CASE + WHEN (SELECT total_hours FROM total_working_hours) > 0 + THEN ROUND((COALESCE(SUM(twl.time_spent), 0) / 3600.0) / (SELECT total_hours FROM total_working_hours) * 100, 2) + ELSE 0 + END as utilization_percent, + ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0, 2) as utilized_hours, + ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0 - (SELECT total_hours FROM total_working_hours), 2) as over_under_utilized_hours, + '${userTimezone}' as user_timezone, + '${startDate.format("YYYY-MM-DD")}' as report_start_date, + '${endDate.format("YYYY-MM-DD")}' as report_end_date + FROM team_members tm + LEFT JOIN users u ON tm.user_id = u.id + LEFT JOIN task_work_log twl ON twl.user_id = u.id + LEFT JOIN tasks t ON twl.task_id = t.id ${billableQuery} + LEFT JOIN projects p ON t.project_id = p.id + WHERE tm.team_id IN (${teamIds}) + AND ( + twl.id IS NULL + OR ( + p.id IN (${projectIds}) + AND twl.created_at >= '${startUtc}'::TIMESTAMP + AND twl.created_at <= '${endUtc}'::TIMESTAMP + ${archivedClause} + ) + ) + GROUP BY u.id, u.email, tm.name, tm.color_code + ORDER BY logged_time DESC`; + + const result = await db.query(q, []); + + // Add timezone context to response + const response = { + data: result.rows, + timezone_info: { + user_timezone: userTimezone, + report_period: { + start: startDate.format("YYYY-MM-DD HH:mm:ss z"), + end: endDate.format("YYYY-MM-DD HH:mm:ss z"), + working_days: workingDays, + total_days: totalDays + } + } + }; + + return res.status(200).send(new ServerResponse(true, response)); +} + +async function getUserTimezone(userId: string): Promise { + const q = `SELECT timezone FROM users WHERE id = $1`; + const result = await db.query(q, [userId]); + return result.rows[0]?.timezone || 'UTC'; +} + +function buildBillableQuery(billable: { billable: boolean; nonBillable: boolean }): string { + if (!billable) return ""; + + const { billable: isBillable, nonBillable } = billable; + + if (isBillable && nonBillable) { + return ""; + } else if (isBillable) { + return " AND tasks.billable IS TRUE"; + } else if (nonBillable) { + return " AND tasks.billable IS FALSE"; + } + + return ""; +} \ No newline at end of file diff --git a/worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts b/worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts new file mode 100644 index 00000000..614c1671 --- /dev/null +++ b/worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts @@ -0,0 +1,114 @@ +import WorklenzControllerBase from "../worklenz-controller-base"; +import { IWorkLenzRequest } from "../../interfaces/worklenz-request"; +import db from "../../config/db"; +import moment from "moment-timezone"; +import { DATE_RANGES } from "../../shared/constants"; + +export default abstract class ReportingControllerBaseWithTimezone extends WorklenzControllerBase { + + /** + * Get the user's timezone from the database or request + * @param userId - The user ID + * @returns The user's timezone or 'UTC' as default + */ + protected static async getUserTimezone(userId: string): Promise { + const q = `SELECT timezone FROM users WHERE id = $1`; + const result = await db.query(q, [userId]); + return result.rows[0]?.timezone || 'UTC'; + } + + /** + * Generate date range clause with timezone support + * @param key - Date range key (e.g., YESTERDAY, LAST_WEEK) + * @param dateRange - Array of date strings + * @param userTimezone - User's timezone (e.g., 'America/New_York') + * @returns SQL clause for date filtering + */ + protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) { + // For custom date ranges + if (dateRange.length === 2) { + // Convert dates to user's timezone start/end of day + const start = moment.tz(dateRange[0], userTimezone).startOf('day'); + const end = moment.tz(dateRange[1], userTimezone).endOf('day'); + + // Convert to UTC for database comparison + const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss"); + const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss"); + + if (start.isSame(end, 'day')) { + // Single day selection + return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`; + } + + return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`; + } + + // For predefined ranges, calculate based on user's timezone + const now = moment.tz(userTimezone); + let startDate, endDate; + + switch (key) { + case DATE_RANGES.YESTERDAY: + startDate = now.clone().subtract(1, 'day').startOf('day'); + endDate = now.clone().subtract(1, 'day').endOf('day'); + break; + case DATE_RANGES.LAST_WEEK: + startDate = now.clone().subtract(1, 'week').startOf('week'); + endDate = now.clone().subtract(1, 'week').endOf('week'); + break; + case DATE_RANGES.LAST_MONTH: + startDate = now.clone().subtract(1, 'month').startOf('month'); + endDate = now.clone().subtract(1, 'month').endOf('month'); + break; + case DATE_RANGES.LAST_QUARTER: + startDate = now.clone().subtract(3, 'months').startOf('day'); + endDate = now.clone().endOf('day'); + break; + default: + return ""; + } + + if (startDate && endDate) { + const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss"); + const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss"); + return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`; + } + + return ""; + } + + /** + * Format dates for display in user's timezone + * @param date - Date to format + * @param userTimezone - User's timezone + * @param format - Moment format string + * @returns Formatted date string + */ + protected static formatDateInTimezone(date: string | Date, userTimezone: string, format: string = "YYYY-MM-DD HH:mm:ss") { + return moment.tz(date, userTimezone).format(format); + } + + /** + * Get working days count between two dates in user's timezone + * @param startDate - Start date + * @param endDate - End date + * @param userTimezone - User's timezone + * @returns Number of working days + */ + protected static getWorkingDaysInTimezone(startDate: string, endDate: string, userTimezone: string): number { + const start = moment.tz(startDate, userTimezone); + const end = moment.tz(endDate, userTimezone); + let workingDays = 0; + + const current = start.clone(); + while (current.isSameOrBefore(end, 'day')) { + // Monday = 1, Friday = 5 + if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) { + workingDays++; + } + current.add(1, 'day'); + } + + return workingDays; + } +} \ No newline at end of file diff --git a/worklenz-backend/src/migrations/add_user_timezone.sql b/worklenz-backend/src/migrations/add_user_timezone.sql new file mode 100644 index 00000000..a2eddb6b --- /dev/null +++ b/worklenz-backend/src/migrations/add_user_timezone.sql @@ -0,0 +1,8 @@ +-- Add timezone column to users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) DEFAULT 'UTC'; + +-- Add index for better query performance +CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone); + +-- Update existing users to use their browser timezone (this would be done via application logic) +COMMENT ON COLUMN users.timezone IS 'IANA timezone identifier (e.g., America/New_York, Asia/Tokyo)'; \ No newline at end of file diff --git a/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.updated.ts b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.updated.ts new file mode 100644 index 00000000..a02b5ca2 --- /dev/null +++ b/worklenz-frontend/src/api/reporting/reporting.timesheet.api.service.updated.ts @@ -0,0 +1,92 @@ +import { API_BASE_URL } from '@/shared/constants'; +import { toQueryString } from '@/utils/toQueryString'; +import apiClient from '../api-client'; +import { IServerResponse } from '@/types/common.types'; +import { IAllocationViewModel } from '@/types/reporting/reporting-allocation.types'; +import { + IProjectLogsBreakdown, + IRPTTimeMember, + IRPTTimeProject, + ITimeLogBreakdownReq, +} from '@/types/reporting/reporting.types'; + +const rootUrl = `${API_BASE_URL}/reporting`; + +// Helper function to get user's timezone +const getUserTimezone = () => { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +}; + +export const reportingTimesheetApiService = { + getTimeSheetData: async ( + body = {}, + archived = false + ): Promise> => { + const q = toQueryString({ archived }); + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/allocation/${q}`, bodyWithTimezone); + return response.data; + }, + + getAllocationProjects: async (body = {}) => { + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/allocation/allocation-projects`, bodyWithTimezone); + return response.data; + }, + + getProjectTimeSheets: async ( + body = {}, + archived = false + ): Promise> => { + const q = toQueryString({ archived }); + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/time-reports/projects/${q}`, bodyWithTimezone); + return response.data; + }, + + getMemberTimeSheets: async ( + body = {}, + archived = false + ): Promise> => { + const q = toQueryString({ archived }); + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, bodyWithTimezone); + return response.data; + }, + + getProjectTimeLogs: async ( + body: ITimeLogBreakdownReq + ): Promise> => { + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/project-timelogs`, bodyWithTimezone); + return response.data; + }, + + getProjectEstimatedVsActual: async ( + body = {}, + archived = false + ): Promise> => { + const q = toQueryString({ archived }); + const bodyWithTimezone = { + ...body, + timezone: getUserTimezone() + }; + const response = await apiClient.post(`${rootUrl}/time-reports/estimated-vs-actual${q}`, bodyWithTimezone); + return response.data; + }, +}; \ No newline at end of file diff --git a/worklenz-frontend/src/hooks/useUserTimezone.ts b/worklenz-frontend/src/hooks/useUserTimezone.ts new file mode 100644 index 00000000..5d665a37 --- /dev/null +++ b/worklenz-frontend/src/hooks/useUserTimezone.ts @@ -0,0 +1,70 @@ +import { useState, useEffect } from 'react'; + +/** + * Custom hook to get and manage user's timezone + * @returns {Object} Object containing timezone and related utilities + */ +export const useUserTimezone = () => { + const [timezone, setTimezone] = useState('UTC'); + const [timezoneOffset, setTimezoneOffset] = useState('+00:00'); + + useEffect(() => { + // Get browser's timezone + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + setTimezone(browserTimezone); + + // Calculate timezone offset + const date = new Date(); + const offset = -date.getTimezoneOffset(); + const hours = Math.floor(Math.abs(offset) / 60); + const minutes = Math.abs(offset) % 60; + const sign = offset >= 0 ? '+' : '-'; + const formattedOffset = `${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + setTimezoneOffset(formattedOffset); + }, []); + + /** + * Format a date in the user's timezone + * @param date - Date to format + * @param format - Format options + * @returns Formatted date string + */ + const formatInUserTimezone = (date: Date | string, format?: Intl.DateTimeFormatOptions) => { + const dateObj = typeof date === 'string' ? new Date(date) : date; + return dateObj.toLocaleString('en-US', { + timeZone: timezone, + ...format + }); + }; + + /** + * Get the start of day in user's timezone + * @param date - Date to get start of day for + * @returns Date object representing start of day + */ + const getStartOfDayInTimezone = (date: Date = new Date()) => { + const localDate = new Date(date.toLocaleString('en-US', { timeZone: timezone })); + localDate.setHours(0, 0, 0, 0); + return localDate; + }; + + /** + * Get the end of day in user's timezone + * @param date - Date to get end of day for + * @returns Date object representing end of day + */ + const getEndOfDayInTimezone = (date: Date = new Date()) => { + const localDate = new Date(date.toLocaleString('en-US', { timeZone: timezone })); + localDate.setHours(23, 59, 59, 999); + return localDate; + }; + + return { + timezone, + timezoneOffset, + formatInUserTimezone, + getStartOfDayInTimezone, + getEndOfDayInTimezone, + setTimezone + }; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports-updated.tsx b/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports-updated.tsx new file mode 100644 index 00000000..7eb4e5d6 --- /dev/null +++ b/worklenz-frontend/src/pages/reporting/timeReports/members-time-reports-updated.tsx @@ -0,0 +1,67 @@ +import { Card, Flex, Typography, Tooltip } from '@/shared/antd-imports'; +import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header'; +import MembersTimeSheet, { + MembersTimeSheetRef, +} from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet'; +import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader'; +import { useTranslation } from 'react-i18next'; +import { useDocumentTitle } from '@/hooks/useDoumentTItle'; +import { useRef } from 'react'; +import { useUserTimezone } from '@/hooks/useUserTimezone'; +import { InfoCircleOutlined } from '@ant-design/icons'; + +const { Text } = Typography; + +const MembersTimeReports = () => { + const { t } = useTranslation('time-report'); + const chartRef = useRef(null); + const { timezone, timezoneOffset } = useUserTimezone(); + + useDocumentTitle('Reporting - Allocation'); + + const handleExport = (type: string) => { + if (type === 'png') { + chartRef.current?.exportChart(); + } + }; + + return ( + + + + + + + + + + Timezone: {timezone} ({timezoneOffset}) + + + + + } + styles={{ + body: { + maxHeight: 'calc(100vh - 300px)', + overflowY: 'auto', + padding: '16px', + }, + }} + > + + + + ); +}; + +export default MembersTimeReports; \ No newline at end of file From de26417247aabacfe4933ec78b497c763cc74dc1 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Thu, 24 Jul 2025 09:25:50 +0530 Subject: [PATCH 2/4] refactor(reporting): enhance timezone handling and clean up migration - Updated SQL queries in reporting controllers to join with the timezones table for accurate timezone retrieval. - Refactored ReportingMembersController to extend ReportingControllerBaseWithTimezone for centralized timezone logic. - Removed obsolete migration file that added a timezone column to the users table, as it is no longer needed. --- .claude/settings.local.json | 4 ++- ...reporting-allocation-controller-updated.ts | 25 +++++++++++-------- ...reporting-controller-base-with-timezone.ts | 5 +++- .../reporting/reporting-members-controller.ts | 12 ++++++--- .../src/migrations/add_user_timezone.sql | 8 ------ 5 files changed, 29 insertions(+), 25 deletions(-) delete mode 100644 worklenz-backend/src/migrations/add_user_timezone.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 06f61982..b6f137d5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "Bash(find:*)", "Bash(npm run build:*)", "Bash(npm run type-check:*)", - "Bash(npm run:*)" + "Bash(npm run:*)", + "Bash(grep:*)", + "Bash(rm:*)" ], "deny": [] } diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts index d416d0b6..9d23c03d 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller-updated.ts @@ -14,10 +14,10 @@ export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzR const teamIds = teams.map(id => `'${id}'`).join(","); const projects = (req.body.projects || []) as string[]; const projectIds = projects.map(p => `'${p}'`).join(","); - const billable = req.body.billable; + const {billable} = req.body; // Get user timezone from request or database - const userTimezone = req.body.timezone || await getUserTimezone(req.user?.id || ''); + const userTimezone = req.body.timezone || await getUserTimezone(req.user?.id || ""); if (!teamIds || !projectIds.length) return res.status(200).send(new ServerResponse(true, { users: [], projects: [] })); @@ -30,13 +30,13 @@ export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzR if (date_range && date_range.length === 2) { // Convert user's local dates to their timezone's start/end of day - startDate = moment.tz(date_range[0], userTimezone).startOf('day'); - endDate = moment.tz(date_range[1], userTimezone).endOf('day'); + startDate = moment.tz(date_range[0], userTimezone).startOf("day"); + endDate = moment.tz(date_range[1], userTimezone).endOf("day"); } else if (duration === DATE_RANGES.ALL_TIME) { const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`; const minDateResult = await db.query(minDateQuery, []); const minDate = minDateResult.rows[0]?.min_date; - startDate = minDate ? moment.tz(minDate, userTimezone) : moment.tz('2000-01-01', userTimezone); + startDate = minDate ? moment.tz(minDate, userTimezone) : moment.tz("2000-01-01", userTimezone); endDate = moment.tz(userTimezone); } else { // Calculate ranges based on user's timezone @@ -44,8 +44,8 @@ export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzR switch (duration) { case DATE_RANGES.YESTERDAY: - startDate = now.clone().subtract(1, "day").startOf('day'); - endDate = now.clone().subtract(1, "day").endOf('day'); + startDate = now.clone().subtract(1, "day").startOf("day"); + endDate = now.clone().subtract(1, "day").endOf("day"); break; case DATE_RANGES.LAST_WEEK: startDate = now.clone().subtract(1, "week").startOf("isoWeek"); @@ -74,11 +74,11 @@ export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzR let workingDays = 0; const current = startDate.clone(); - while (current.isSameOrBefore(endDate, 'day')) { + while (current.isSameOrBefore(endDate, "day")) { if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) { workingDays++; } - current.add(1, 'day'); + current.add(1, "day"); } // Updated SQL query with proper timezone handling @@ -154,9 +154,12 @@ export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzR } async function getUserTimezone(userId: string): Promise { - const q = `SELECT timezone FROM users WHERE id = $1`; + const q = `SELECT tz.name as timezone + FROM users u + JOIN timezones tz ON u.timezone_id = tz.id + WHERE u.id = $1`; const result = await db.query(q, [userId]); - return result.rows[0]?.timezone || 'UTC'; + return result.rows[0]?.timezone || "UTC"; } function buildBillableQuery(billable: { billable: boolean; nonBillable: boolean }): string { diff --git a/worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts b/worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts index 614c1671..59fc9a50 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-controller-base-with-timezone.ts @@ -12,7 +12,10 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle * @returns The user's timezone or 'UTC' as default */ protected static async getUserTimezone(userId: string): Promise { - const q = `SELECT timezone FROM users WHERE id = $1`; + const q = `SELECT tz.name as timezone + FROM users u + JOIN timezones tz ON u.timezone_id = tz.id + WHERE u.id = $1`; const result = await db.query(q, [userId]); return result.rows[0]?.timezone || 'UTC'; } diff --git a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts index 97500437..d2d674eb 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts @@ -6,10 +6,10 @@ import { IWorkLenzResponse } from "../../interfaces/worklenz-response"; import { ServerResponse } from "../../models/server-response"; import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants"; import { formatDuration, getColor, int } from "../../shared/utils"; -import ReportingControllerBase from "./reporting-controller-base"; +import ReportingControllerBaseWithTimezone from "./reporting-controller-base-with-timezone"; import Excel from "exceljs"; -export default class ReportingMembersController extends ReportingControllerBase { +export default class ReportingMembersController extends ReportingControllerBaseWithTimezone { private static async getMembers( teamId: string, searchQuery = "", @@ -487,7 +487,9 @@ export default class ReportingMembersController extends ReportingControllerBase dateRange = date_range.split(","); } - const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "twl"); + // Get user timezone for proper date filtering + const userTimezone = await this.getUserTimezone(req.user?.id as string); + const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone); const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log"); const memberName = (req.query.member_name as string)?.trim() || null; @@ -1038,7 +1040,9 @@ export default class ReportingMembersController extends ReportingControllerBase public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { team_member_id, team_id, duration, date_range, archived, billable } = req.body; - const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "twl"); + // Get user timezone for proper date filtering + const userTimezone = await this.getUserTimezone(req.user?.id as string); + const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone); const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log"); const billableQuery = this.buildBillableQuery(billable); diff --git a/worklenz-backend/src/migrations/add_user_timezone.sql b/worklenz-backend/src/migrations/add_user_timezone.sql deleted file mode 100644 index a2eddb6b..00000000 --- a/worklenz-backend/src/migrations/add_user_timezone.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Add timezone column to users table -ALTER TABLE users ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) DEFAULT 'UTC'; - --- Add index for better query performance -CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone); - --- Update existing users to use their browser timezone (this would be done via application logic) -COMMENT ON COLUMN users.timezone IS 'IANA timezone identifier (e.g., America/New_York, Asia/Tokyo)'; \ No newline at end of file From daa65465dd727b5c59ee8f4da8bcd7fcd37a9bb4 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Thu, 24 Jul 2025 09:56:23 +0530 Subject: [PATCH 3/4] feat(reporting): add utility methods for date and time calculations in ReportingMembersController - Introduced methods to calculate percentage, convert time formats, and determine date-related metrics such as days left, overdue status, and if a date is today. - Updated existing logic in getSingleMemberProjects to utilize the new utility methods for improved clarity and maintainability. --- .../reporting/reporting-members-controller.ts | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts index d2d674eb..5789bf02 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts @@ -11,6 +11,65 @@ import Excel from "exceljs"; export default class ReportingMembersController extends ReportingControllerBaseWithTimezone { + protected static getPercentage(n: number, total: number) { + return +(n ? (n / total) * 100 : 0).toFixed(); + } + + protected static getCurrentTeamId(req: IWorkLenzRequest): string | null { + return req.user?.team_id ?? null; + } + + public static convertMinutesToHoursAndMinutes(totalMinutes: number) { + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}h ${minutes}m`; + } + + public static convertSecondsToHoursAndMinutes(seconds: number) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + } + + protected static formatEndDate(endDate: string) { + const end = moment(endDate).format("YYYY-MM-DD"); + const fEndDate = moment(end); + return fEndDate; + } + + protected static formatCurrentDate() { + const current = moment().format("YYYY-MM-DD"); + const fCurrentDate = moment(current); + return fCurrentDate; + } + + protected static getDaysLeft(endDate: string): number | null { + if (!endDate) return null; + + const fCurrentDate = this.formatCurrentDate(); + const fEndDate = this.formatEndDate(endDate); + + return fEndDate.diff(fCurrentDate, "days"); + } + + protected static isOverdue(endDate: string): boolean { + if (!endDate) return false; + + const fCurrentDate = this.formatCurrentDate(); + const fEndDate = this.formatEndDate(endDate); + + return fEndDate.isBefore(fCurrentDate); + } + + protected static isToday(endDate: string): boolean { + if (!endDate) return false; + + const fCurrentDate = this.formatCurrentDate(); + const fEndDate = this.formatEndDate(endDate); + + return fEndDate.isSame(fCurrentDate); + } + private static async getMembers( teamId: string, searchQuery = "", size: number | null = null, @@ -1234,8 +1293,8 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen row.actual_time = int(row.actual_time); row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time)); row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time)); - row.days_left = ReportingControllerBase.getDaysLeft(row.end_date); - row.is_overdue = ReportingControllerBase.isOverdue(row.end_date); + row.days_left = this.getDaysLeft(row.end_date); + row.is_overdue = this.isOverdue(row.end_date); if (row.days_left && row.is_overdue) { row.days_left = row.days_left.toString().replace(/-/g, ""); } From 7ea05d2982f6faec8d107ed9f497192233bc312c Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Thu, 24 Jul 2025 10:16:09 +0530 Subject: [PATCH 4/4] feat(database): add performance indexes for optimized task queries - Introduced multiple new indexes in the SQL schema to enhance query performance for tasks, including composite indexes for filtering and sorting. - Added indexes for various task-related entities such as assignees, phases, labels, comments, and attachments to improve data retrieval efficiency. - Implemented covering indexes to optimize task aggregation and search functionalities, ensuring faster access to frequently queried data. --- worklenz-backend/database/sql/indexes.sql | 136 ++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/worklenz-backend/database/sql/indexes.sql b/worklenz-backend/database/sql/indexes.sql index b9df6ab1..1c9b3855 100644 --- a/worklenz-backend/database/sql/indexes.sql +++ b/worklenz-backend/database/sql/indexes.sql @@ -132,3 +132,139 @@ CREATE INDEX IF NOT EXISTS projects_team_id_index CREATE INDEX IF NOT EXISTS projects_team_id_name_index ON projects (team_id, name); +-- Performance indexes for optimized tasks queries +-- From migration: 20250115000000-performance-indexes.sql + +-- Composite index for main task filtering +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent +ON tasks(project_id, archived, parent_task_id) +WHERE archived = FALSE; + +-- Index for status joins +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project +ON tasks(status_id, project_id) +WHERE archived = FALSE; + +-- Index for assignees lookup +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member +ON tasks_assignees(task_id, team_member_id); + +-- Index for phase lookup +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase +ON task_phase(task_id, phase_id); + +-- Index for subtask counting +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived +ON tasks(parent_task_id, archived) +WHERE parent_task_id IS NOT NULL AND archived = FALSE; + +-- Index for labels +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label +ON task_labels(task_id, label_id); + +-- Index for comments count +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task +ON task_comments(task_id); + +-- Index for attachments count +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task +ON task_attachments(task_id); + +-- Index for work log aggregation +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task +ON task_work_log(task_id); + +-- Index for subscribers check +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task +ON task_subscribers(task_id); + +-- Index for dependencies check +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task +ON task_dependencies(task_id); + +-- Index for timers lookup +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_task_user +ON task_timers(task_id, user_id); + +-- Index for custom columns +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task +ON cc_column_values(task_id); + +-- Index for team member info view optimization +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_team_user +ON team_members(team_id, user_id) +WHERE active = TRUE; + +-- Index for notification settings +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notification_settings_user_team +ON notification_settings(user_id, team_id); + +-- Index for task status categories +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_category +ON task_statuses(category_id, project_id); + +-- Index for project phases +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_phases_project_sort +ON project_phases(project_id, sort_index); + +-- Index for task priorities +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_priorities_value +ON task_priorities(value); + +-- Index for team labels +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team +ON team_labels(team_id); + +-- Advanced performance indexes for task optimization + +-- Composite index for task main query optimization (covers most WHERE conditions) +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_performance_main +ON tasks(project_id, archived, parent_task_id, status_id, priority_id) +WHERE archived = FALSE; + +-- Index for sorting by sort_order with project filter +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort_order +ON tasks(project_id, sort_order) +WHERE archived = FALSE; + +-- Index for email_invitations to optimize team_member_info_view +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_invitations_team_member +ON email_invitations(team_member_id); + +-- Covering index for task status with category information +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_covering +ON task_statuses(id, category_id, project_id); + +-- Index for task aggregation queries (parent task progress calculation) +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_status_archived +ON tasks(parent_task_id, status_id, archived) +WHERE archived = FALSE; + +-- Index for project team member filtering +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_project_lookup +ON team_members(team_id, active, user_id) +WHERE active = TRUE; + +-- Covering index for tasks with frequently accessed columns +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_covering_main +ON tasks(id, project_id, archived, parent_task_id, status_id, priority_id, sort_order, name) +WHERE archived = FALSE; + +-- Index for task search functionality +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_name_search +ON tasks USING gin(to_tsvector('english', name)) +WHERE archived = FALSE; + +-- Index for date-based filtering (if used) +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_dates +ON tasks(project_id, start_date, end_date) +WHERE archived = FALSE; + +-- Index for task timers with user filtering +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_user_task +ON task_timers(user_id, task_id); + +-- Index for sys_task_status_categories lookups +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sys_task_status_categories_covering +ON sys_task_status_categories(id, color_code, color_code_dark, is_done, is_doing, is_todo); +