From 75391641fda99a0210db80901536b284d3fe8ffe Mon Sep 17 00:00:00 2001 From: MRNafisiA Date: Fri, 2 May 2025 15:53:27 +0330 Subject: [PATCH 01/35] increase the memory limit to prevent crashing during build time. --- worklenz-frontend/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worklenz-frontend/Dockerfile b/worklenz-frontend/Dockerfile index a32f879e..46a87fa7 100644 --- a/worklenz-frontend/Dockerfile +++ b/worklenz-frontend/Dockerfile @@ -12,7 +12,7 @@ COPY . . RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \ echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js -RUN npm run build +RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build FROM node:22-alpine AS production From 62548e5c37f04df23424ebfc6cbc3d22cab90178 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 14 May 2025 15:41:09 +0530 Subject: [PATCH 02/35] feat(task-drawer): add recurring task configuration Add support for configuring recurring tasks in the task drawer. This includes adding a new `schedule_id` field to the task type, creating a new `TaskDrawerRecurringConfig` component, and updating localization files for English, Spanish, and Portuguese. The configuration allows setting repeat intervals, days of the week, and monthly recurrence options. --- .../en/task-drawer/task-drawer-info-tab.json | 3 +- .../task-drawer-recurring-config.json | 33 +++ .../es/task-drawer/task-drawer-info-tab.json | 3 +- .../task-drawer-recurring-config.json | 33 +++ .../pt/task-drawer/task-drawer-info-tab.json | 3 +- .../task-drawer-recurring-config.json | 33 +++ .../task-drawer-recurring-config.tsx | 245 ++++++++++++++++++ .../shared/info-tab/task-details-form.tsx | 5 + .../types/tasks/task-recurring-schedule.ts | 19 ++ .../src/types/tasks/task.types.ts | 1 + 10 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json create mode 100644 worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json create mode 100644 worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json create mode 100644 worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx create mode 100644 worklenz-frontend/src/types/tasks/task-recurring-schedule.ts diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json index 42ffdc83..b5caeb72 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-info-tab.json @@ -15,7 +15,8 @@ "hide-start-date": "Hide Start Date", "show-start-date": "Show Start Date", "hours": "Hours", - "minutes": "Minutes" + "minutes": "Minutes", + "recurring": "Recurring" }, "description": { "title": "Description", diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..f1d0301d --- /dev/null +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,33 @@ +{ + "recurring": "Recurring", + "recurringTaskConfiguration": "Recurring task configuration", + "repeats": "Repeats", + "weekly": "Weekly", + "everyXDays": "Every X Days", + "everyXWeeks": "Every X Weeks", + "everyXMonths": "Every X Months", + "monthly": "Monthly", + "selectDaysOfWeek": "Select Days of the Week", + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat", + "sun": "Sun", + "monthlyRepeatType": "Monthly repeat type", + "onSpecificDate": "On a specific date", + "onSpecificDay": "On a specific day", + "dateOfMonth": "Date of the month", + "weekOfMonth": "Week of the month", + "dayOfWeek": "Day of the week", + "first": "First", + "second": "Second", + "third": "Third", + "fourth": "Fourth", + "last": "Last", + "intervalDays": "Interval (days)", + "intervalWeeks": "Interval (weeks)", + "intervalMonths": "Interval (months)", + "saveChanges": "Save Changes" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json index 58c5715e..cdafd81c 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-info-tab.json @@ -15,7 +15,8 @@ "hide-start-date": "Ocultar fecha de inicio", "show-start-date": "Mostrar fecha de inicio", "hours": "Horas", - "minutes": "Minutos" + "minutes": "Minutos", + "recurring": "Recurrente" }, "description": { "title": "Descripción", diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..d9c711a5 --- /dev/null +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,33 @@ +{ + "recurring": "Recurrente", + "recurringTaskConfiguration": "Configuración de tarea recurrente", + "repeats": "Repeticiones", + "weekly": "Semanal", + "everyXDays": "Cada X días", + "everyXWeeks": "Cada X semanas", + "everyXMonths": "Cada X meses", + "monthly": "Mensual", + "selectDaysOfWeek": "Seleccionar días de la semana", + "mon": "Lun", + "tue": "Mar", + "wed": "Mié", + "thu": "Jue", + "fri": "Vie", + "sat": "Sáb", + "sun": "Dom", + "monthlyRepeatType": "Tipo de repetición mensual", + "onSpecificDate": "En una fecha específica", + "onSpecificDay": "En un día específico", + "dateOfMonth": "Fecha del mes", + "weekOfMonth": "Semana del mes", + "dayOfWeek": "Día de la semana", + "first": "Primero", + "second": "Segundo", + "third": "Tercero", + "fourth": "Cuarto", + "last": "Último", + "intervalDays": "Intervalo (días)", + "intervalWeeks": "Intervalo (semanas)", + "intervalMonths": "Intervalo (meses)", + "saveChanges": "Guardar cambios" +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json index 48922a52..fde2215a 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-info-tab.json @@ -15,7 +15,8 @@ "hide-start-date": "Ocultar data de início", "show-start-date": "Mostrar data de início", "hours": "Horas", - "minutes": "Minutos" + "minutes": "Minutos", + "recurring": "Recorrente" }, "description": { "title": "Descrição", diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json new file mode 100644 index 00000000..5619884b --- /dev/null +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json @@ -0,0 +1,33 @@ +{ + "recurring": "Recorrente", + "recurringTaskConfiguration": "Configuração de tarefa recorrente", + "repeats": "Repete", + "weekly": "Semanal", + "everyXDays": "A cada X dias", + "everyXWeeks": "A cada X semanas", + "everyXMonths": "A cada X meses", + "monthly": "Mensal", + "selectDaysOfWeek": "Selecionar dias da semana", + "mon": "Seg", + "tue": "Ter", + "wed": "Qua", + "thu": "Qui", + "fri": "Sex", + "sat": "Sáb", + "sun": "Dom", + "monthlyRepeatType": "Tipo de repetição mensal", + "onSpecificDate": "Em uma data específica", + "onSpecificDay": "Em um dia específico", + "dateOfMonth": "Data do mês", + "weekOfMonth": "Semana do mês", + "dayOfWeek": "Dia da semana", + "first": "Primeira", + "second": "Segunda", + "third": "Terceira", + "fourth": "Quarta", + "last": "Última", + "intervalDays": "Intervalo (dias)", + "intervalWeeks": "Intervalo (semanas)", + "intervalMonths": "Intervalo (meses)", + "saveChanges": "Salvar alterações" +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx new file mode 100644 index 00000000..56daee49 --- /dev/null +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx @@ -0,0 +1,245 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { Form, Switch, Button, Popover, Select, Checkbox, Radio, InputNumber, Skeleton, Row, Col } from 'antd'; +import { SettingOutlined } from '@ant-design/icons'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; +import { ITaskViewModel } from '@/types/tasks/task.types'; +import { useTranslation } from 'react-i18next'; + +// Dummy enums and types for demonstration; replace with actual imports/types +const ITaskRecurring = { + Weekly: 'weekly', + EveryXDays: 'every_x_days', + EveryXWeeks: 'every_x_weeks', + EveryXMonths: 'every_x_months', + Monthly: 'monthly', +}; + +const repeatOptions = [ + { label: 'Weekly', value: ITaskRecurring.Weekly }, + { label: 'Every X Days', value: ITaskRecurring.EveryXDays }, + { label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks }, + { label: 'Every X Months', value: ITaskRecurring.EveryXMonths }, + { label: 'Monthly', value: ITaskRecurring.Monthly }, +]; + +const daysOfWeek = [ + { label: 'Mon', value: 'mon' }, + { label: 'Tue', value: 'tue' }, + { label: 'Wed', value: 'wed' }, + { label: 'Thu', value: 'thu' }, + { label: 'Fri', value: 'fri' }, + { label: 'Sat', value: 'sat' }, + { label: 'Sun', value: 'sun' }, +]; + +const monthlyDateOptions = Array.from({ length: 31 }, (_, i) => i + 1); +const weekOptions = [ + { label: 'First', value: 'first' }, + { label: 'Second', value: 'second' }, + { label: 'Third', value: 'third' }, + { label: 'Fourth', value: 'fourth' }, + { label: 'Last', value: 'last' }, +]; +const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); + +const TaskDrawerRecurringConfig = ({ task }: {task: ITaskViewModel}) => { + const { socket, connected } = useSocket(); + const { t } = useTranslation('task-drawer/task-drawer-recurring-config'); + + const [recurring, setRecurring] = useState(false); + const [showConfig, setShowConfig] = useState(false); + const [repeatOption, setRepeatOption] = useState(repeatOptions[0]); + const [selectedDays, setSelectedDays] = useState([]); + const [monthlyOption, setMonthlyOption] = useState('date'); + const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1); + const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value); + const [selectedMonthlyDay, setSelectedMonthlyDay] = useState(dayOptions[0].value); + const [intervalDays, setIntervalDays] = useState(1); + const [intervalWeeks, setIntervalWeeks] = useState(1); + const [intervalMonths, setIntervalMonths] = useState(1); + const [loadingData, setLoadingData] = useState(false); + const [updatingData, setUpdatingData] = useState(false); + + const handleChange = (checked: boolean) => { + setRecurring(checked); + if (!checked) setShowConfig(false); + }; + + const configVisibleChange = (visible: boolean) => { + setShowConfig(visible); + }; + + const isMonthlySelected = useMemo(() => repeatOption.value === ITaskRecurring.Monthly, [repeatOption]); + + const handleDayCheckboxChange = (checkedValues: string[]) => { + setSelectedDays(checkedValues as unknown as string[]); + }; + + const handleSave = () => { + // Compose the schedule data and call the update handler + const data = { + recurring, + repeatOption, + selectedDays, + monthlyOption, + selectedMonthlyDate, + selectedMonthlyWeek, + selectedMonthlyDay, + intervalDays, + intervalWeeks, + intervalMonths, + }; + // if (onUpdateSchedule) onUpdateSchedule(data); + setShowConfig(false); + }; + + const getScheduleData = () => { + + }; + + const handleResponse = (response: ITaskRecurringScheduleData) => { + if (!task || !response.task_id) return; + } + + useEffect(() => { + if (task) setRecurring(!!task.schedule_id); + if (recurring) void getScheduleData(); + socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse) + }, []) + + return ( +
+ +
+ +   + {recurring && ( + +
+ + ({ label: date.toString(), value: date }))} + style={{ width: 120 }} + /> + + )} + {monthlyOption === 'day' && ( + <> + + + + + )} + + )} + + {repeatOption.value === ITaskRecurring.EveryXDays && ( + + value && setIntervalDays(value)} /> + + )} + {repeatOption.value === ITaskRecurring.EveryXWeeks && ( + + value && setIntervalWeeks(value)} /> + + )} + {repeatOption.value === ITaskRecurring.EveryXMonths && ( + + value && setIntervalMonths(value)} /> + + )} + + + +
+ + } + overlayStyle={{ width: 510 }} + open={showConfig} + onOpenChange={configVisibleChange} + trigger="click" + > + +
+ )} +
+
+
+ ); +}; + +export default TaskDrawerRecurringConfig; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx index f9792485..a2dcaef1 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx @@ -29,6 +29,7 @@ import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billa import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progress'; import { useAppSelector } from '@/hooks/useAppSelector'; import logger from '@/utils/errorLogger'; +import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/task-drawer-recurring-config'; interface TaskDetailsFormProps { taskFormViewModel?: ITaskFormViewModel | null; @@ -175,6 +176,10 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => + + + + diff --git a/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts new file mode 100644 index 00000000..8fc708d5 --- /dev/null +++ b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts @@ -0,0 +1,19 @@ +export interface ITaskRecurringSchedule { + type: 'daily' | 'weekly' | 'monthly' | 'interval'; + dayOfWeek?: number; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday (for weekly tasks) + dayOfMonth?: number; // 1 - 31 (for monthly tasks) + weekOfMonth?: number; // 1 = 1st week, 2 = 2nd week, ..., 5 = Last week (for monthly tasks) + hour: number; // Time of the day in 24-hour format + minute: number; // Minute of the hour + interval?: { + days?: number; // Interval in days (for every x days) + weeks?: number; // Interval in weeks (for every x weeks) + months?: number; // Interval in months (for every x months) + }; +} + +export interface ITaskRecurringScheduleData { + task_id?: string, + id?: string, + schedule_type?: string +} \ No newline at end of file diff --git a/worklenz-frontend/src/types/tasks/task.types.ts b/worklenz-frontend/src/types/tasks/task.types.ts index 9c5da9bf..d155490c 100644 --- a/worklenz-frontend/src/types/tasks/task.types.ts +++ b/worklenz-frontend/src/types/tasks/task.types.ts @@ -64,6 +64,7 @@ export interface ITaskViewModel extends ITask { timer_start_time?: number; recurring?: boolean; task_level?: number; + schedule_id?: string | null; } export interface ITaskTeamMember extends ITeamMember { From d333104f438a956188cbc3041bc1bec03202ec96 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Fri, 16 May 2025 07:21:57 +0530 Subject: [PATCH 03/35] feat(i18n): add recurring task translation keys Add new localization entries for the recurring task feature in English, Spanish, and Portuguese. This update includes the addition of the "recurring" key to the task drawer JSON files, enhancing support for recurring task configurations across multiple languages. --- .../public/locales/en/task-drawer/task-drawer.json | 3 ++- .../public/locales/es/task-drawer/task-drawer.json | 3 ++- .../public/locales/pt/task-drawer/task-drawer.json | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json index e013b4f2..06575ee1 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer.json @@ -30,7 +30,8 @@ "taskWeight": "Task Weight", "taskWeightTooltip": "Set the weight of this subtask (percentage)", "taskWeightRequired": "Please enter a task weight", - "taskWeightRange": "Weight must be between 0 and 100" + "taskWeightRange": "Weight must be between 0 and 100", + "recurring": "Recurring" }, "labels": { "labelInputPlaceholder": "Search or create", diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json index 8b3ef220..c3980da8 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer.json @@ -30,7 +30,8 @@ "taskWeight": "Peso de la Tarea", "taskWeightTooltip": "Establecer el peso de esta subtarea (porcentaje)", "taskWeightRequired": "Por favor, introduce un peso para la tarea", - "taskWeightRange": "El peso debe estar entre 0 y 100" + "taskWeightRange": "El peso debe estar entre 0 y 100", + "recurring": "Recurrente" }, "labels": { "labelInputPlaceholder": "Buscar o crear", diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json index 7a3933f2..6288af92 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer.json @@ -30,7 +30,8 @@ "taskWeight": "Peso da Tarefa", "taskWeightTooltip": "Definir o peso desta subtarefa (porcentagem)", "taskWeightRequired": "Por favor, insira um peso para a tarefa", - "taskWeightRange": "O peso deve estar entre 0 e 100" + "taskWeightRange": "O peso deve estar entre 0 e 100", + "recurring": "Recorrente" }, "labels": { "labelInputPlaceholder": "Pesquisar ou criar", From ebd0f6676821473f613b4810c0f27d74ab43d281 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 16 May 2025 12:37:02 +0530 Subject: [PATCH 04/35] feat(task-drawer): integrate socket handling for recurring task updates Enhance the TaskDrawerRecurringConfig component to include socket communication for handling recurring task changes. This update introduces the use of Redux for managing state updates related to recurring schedules, ensuring real-time synchronization of task configurations. Additionally, the code has been refactored for improved readability and maintainability. --- .../task-drawer-recurring-config.tsx | 480 ++++++++++-------- .../src/features/tasks/tasks.slice.ts | 11 + 2 files changed, 275 insertions(+), 216 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx index 56daee49..1e5af1d8 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx @@ -1,245 +1,293 @@ import React, { useState, useMemo, useEffect } from 'react'; -import { Form, Switch, Button, Popover, Select, Checkbox, Radio, InputNumber, Skeleton, Row, Col } from 'antd'; +import { + Form, + Switch, + Button, + Popover, + Select, + Checkbox, + Radio, + InputNumber, + Skeleton, + Row, + Col, +} from 'antd'; import { SettingOutlined } from '@ant-design/icons'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; import { ITaskViewModel } from '@/types/tasks/task.types'; import { useTranslation } from 'react-i18next'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { updateRecurringChange } from '@/features/tasks/tasks.slice'; -// Dummy enums and types for demonstration; replace with actual imports/types const ITaskRecurring = { - Weekly: 'weekly', - EveryXDays: 'every_x_days', - EveryXWeeks: 'every_x_weeks', - EveryXMonths: 'every_x_months', - Monthly: 'monthly', + Weekly: 'weekly', + EveryXDays: 'every_x_days', + EveryXWeeks: 'every_x_weeks', + EveryXMonths: 'every_x_months', + Monthly: 'monthly', }; const repeatOptions = [ - { label: 'Weekly', value: ITaskRecurring.Weekly }, - { label: 'Every X Days', value: ITaskRecurring.EveryXDays }, - { label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks }, - { label: 'Every X Months', value: ITaskRecurring.EveryXMonths }, - { label: 'Monthly', value: ITaskRecurring.Monthly }, + { label: 'Weekly', value: ITaskRecurring.Weekly }, + { label: 'Every X Days', value: ITaskRecurring.EveryXDays }, + { label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks }, + { label: 'Every X Months', value: ITaskRecurring.EveryXMonths }, + { label: 'Monthly', value: ITaskRecurring.Monthly }, ]; const daysOfWeek = [ - { label: 'Mon', value: 'mon' }, - { label: 'Tue', value: 'tue' }, - { label: 'Wed', value: 'wed' }, - { label: 'Thu', value: 'thu' }, - { label: 'Fri', value: 'fri' }, - { label: 'Sat', value: 'sat' }, - { label: 'Sun', value: 'sun' }, + { label: 'Mon', value: 'mon' }, + { label: 'Tue', value: 'tue' }, + { label: 'Wed', value: 'wed' }, + { label: 'Thu', value: 'thu' }, + { label: 'Fri', value: 'fri' }, + { label: 'Sat', value: 'sat' }, + { label: 'Sun', value: 'sun' }, ]; const monthlyDateOptions = Array.from({ length: 31 }, (_, i) => i + 1); const weekOptions = [ - { label: 'First', value: 'first' }, - { label: 'Second', value: 'second' }, - { label: 'Third', value: 'third' }, - { label: 'Fourth', value: 'fourth' }, - { label: 'Last', value: 'last' }, + { label: 'First', value: 'first' }, + { label: 'Second', value: 'second' }, + { label: 'Third', value: 'third' }, + { label: 'Fourth', value: 'fourth' }, + { label: 'Last', value: 'last' }, ]; const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); -const TaskDrawerRecurringConfig = ({ task }: {task: ITaskViewModel}) => { - const { socket, connected } = useSocket(); - const { t } = useTranslation('task-drawer/task-drawer-recurring-config'); +const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { + const { socket, connected } = useSocket(); + const dispatch = useAppDispatch(); + const { t } = useTranslation('task-drawer/task-drawer-recurring-config'); - const [recurring, setRecurring] = useState(false); - const [showConfig, setShowConfig] = useState(false); - const [repeatOption, setRepeatOption] = useState(repeatOptions[0]); - const [selectedDays, setSelectedDays] = useState([]); - const [monthlyOption, setMonthlyOption] = useState('date'); - const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1); - const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value); - const [selectedMonthlyDay, setSelectedMonthlyDay] = useState(dayOptions[0].value); - const [intervalDays, setIntervalDays] = useState(1); - const [intervalWeeks, setIntervalWeeks] = useState(1); - const [intervalMonths, setIntervalMonths] = useState(1); - const [loadingData, setLoadingData] = useState(false); - const [updatingData, setUpdatingData] = useState(false); + const [recurring, setRecurring] = useState(false); + const [showConfig, setShowConfig] = useState(false); + const [repeatOption, setRepeatOption] = useState(repeatOptions[0]); + const [selectedDays, setSelectedDays] = useState([]); + const [monthlyOption, setMonthlyOption] = useState('date'); + const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1); + const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value); + const [selectedMonthlyDay, setSelectedMonthlyDay] = useState(dayOptions[0].value); + const [intervalDays, setIntervalDays] = useState(1); + const [intervalWeeks, setIntervalWeeks] = useState(1); + const [intervalMonths, setIntervalMonths] = useState(1); + const [loadingData, setLoadingData] = useState(false); + const [updatingData, setUpdatingData] = useState(false); + + const handleChange = (checked: boolean) => { + if (!task.id) return; + + socket?.emit(SocketEvents.TASK_RECURRING_CHANGE.toString(), { + task_id: task.id, + schedule_id: task.schedule_id, + }); + + socket?.once( + SocketEvents.TASK_RECURRING_CHANGE.toString(), + (schedule: ITaskRecurringScheduleData) => { + if (schedule.id && schedule.schedule_type) { + const selected = repeatOptions.find(e => e.value == schedule.schedule_type); + if (selected) setRepeatOption(selected); + } + dispatch(updateRecurringChange(schedule)); - const handleChange = (checked: boolean) => { setRecurring(checked); if (!checked) setShowConfig(false); - }; - - const configVisibleChange = (visible: boolean) => { - setShowConfig(visible); - }; - - const isMonthlySelected = useMemo(() => repeatOption.value === ITaskRecurring.Monthly, [repeatOption]); - - const handleDayCheckboxChange = (checkedValues: string[]) => { - setSelectedDays(checkedValues as unknown as string[]); - }; - - const handleSave = () => { - // Compose the schedule data and call the update handler - const data = { - recurring, - repeatOption, - selectedDays, - monthlyOption, - selectedMonthlyDate, - selectedMonthlyWeek, - selectedMonthlyDay, - intervalDays, - intervalWeeks, - intervalMonths, - }; - // if (onUpdateSchedule) onUpdateSchedule(data); - setShowConfig(false); - }; - - const getScheduleData = () => { - - }; - - const handleResponse = (response: ITaskRecurringScheduleData) => { - if (!task || !response.task_id) return; - } - - useEffect(() => { - if (task) setRecurring(!!task.schedule_id); - if (recurring) void getScheduleData(); - socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse) - }, []) - - return ( -
- -
- -   - {recurring && ( - -
- - ({ label: date.toString(), value: date }))} - style={{ width: 120 }} - /> - - )} - {monthlyOption === 'day' && ( - <> - - - - - )} - - )} - - {repeatOption.value === ITaskRecurring.EveryXDays && ( - - value && setIntervalDays(value)} /> - - )} - {repeatOption.value === ITaskRecurring.EveryXWeeks && ( - - value && setIntervalWeeks(value)} /> - - )} - {repeatOption.value === ITaskRecurring.EveryXMonths && ( - - value && setIntervalMonths(value)} /> - - )} - - - -
- - } - overlayStyle={{ width: 510 }} - open={showConfig} - onOpenChange={configVisibleChange} - trigger="click" - > - -
- )} -
-
-
+ } ); + }; + + const configVisibleChange = (visible: boolean) => { + setShowConfig(visible); + }; + + const isMonthlySelected = useMemo( + () => repeatOption.value === ITaskRecurring.Monthly, + [repeatOption] + ); + + const handleDayCheckboxChange = (checkedValues: string[]) => { + setSelectedDays(checkedValues as unknown as string[]); + }; + + const handleSave = () => { + // Compose the schedule data and call the update handler + const data = { + recurring, + repeatOption, + selectedDays, + monthlyOption, + selectedMonthlyDate, + selectedMonthlyWeek, + selectedMonthlyDay, + intervalDays, + intervalWeeks, + intervalMonths, + }; + // if (onUpdateSchedule) onUpdateSchedule(data); + setShowConfig(false); + }; + + const getScheduleData = () => {}; + + const handleResponse = (response: ITaskRecurringScheduleData) => { + if (!task || !response.task_id) return; + }; + + useEffect(() => { + if (task) setRecurring(!!task.schedule_id); + if (recurring) void getScheduleData(); + socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse); + }, []); + + return ( +
+ +
+ +   + {recurring && ( + +
+ + ({ + label: date.toString(), + value: date, + }))} + style={{ width: 120 }} + /> + + )} + {monthlyOption === 'day' && ( + <> + + + + + )} + + )} + + {repeatOption.value === ITaskRecurring.EveryXDays && ( + + value && setIntervalDays(value)} + /> + + )} + {repeatOption.value === ITaskRecurring.EveryXWeeks && ( + + value && setIntervalWeeks(value)} + /> + + )} + {repeatOption.value === ITaskRecurring.EveryXMonths && ( + + value && setIntervalMonths(value)} + /> + + )} + + + +
+ + } + overlayStyle={{ width: 510 }} + open={showConfig} + onOpenChange={configVisibleChange} + trigger="click" + > + +
+ )} +
+
+
+ ); }; -export default TaskDrawerRecurringConfig; \ No newline at end of file +export default TaskDrawerRecurringConfig; diff --git a/worklenz-frontend/src/features/tasks/tasks.slice.ts b/worklenz-frontend/src/features/tasks/tasks.slice.ts index cd443dbf..49c85e28 100644 --- a/worklenz-frontend/src/features/tasks/tasks.slice.ts +++ b/worklenz-frontend/src/features/tasks/tasks.slice.ts @@ -22,6 +22,7 @@ import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-respon import { produce } from 'immer'; import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service'; import { SocketEvents } from '@/shared/socket-events'; +import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; export enum IGroupBy { STATUS = 'status', @@ -1006,6 +1007,15 @@ const taskSlice = createSlice({ column.pinned = isVisible; } }, + + updateRecurringChange: (state, action: PayloadAction) => { + const {id, schedule_type, task_id} = action.payload; + const taskInfo = findTaskInGroups(state.taskGroups, task_id as string); + if (!taskInfo) return; + + const { task } = taskInfo; + task.schedule_id = id; + } }, extraReducers: builder => { @@ -1165,6 +1175,7 @@ export const { updateSubTasks, updateCustomColumnValue, updateCustomColumnPinned, + updateRecurringChange } = taskSlice.actions; export default taskSlice.reducer; From 2e985bd05161bd337a7904820f3ad17706c3edd8 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 16 May 2025 14:32:45 +0530 Subject: [PATCH 05/35] feat(recurring-tasks): implement recurring task scheduling and API integration --- worklenz-backend/src/cron_jobs/index.ts | 4 +- .../src/cron_jobs/recurring-tasks.ts | 2 +- .../api/tasks/task-recurring.api.service.ts | 16 ++ .../task-drawer-recurring-config.tsx | 164 +++++++++++++----- .../features/task-drawer/task-drawer.slice.ts | 10 ++ .../types/tasks/task-recurring-schedule.ts | 48 +++-- 6 files changed, 185 insertions(+), 59 deletions(-) create mode 100644 worklenz-frontend/src/api/tasks/task-recurring.api.service.ts diff --git a/worklenz-backend/src/cron_jobs/index.ts b/worklenz-backend/src/cron_jobs/index.ts index f13ec2e8..20bd4f62 100644 --- a/worklenz-backend/src/cron_jobs/index.ts +++ b/worklenz-backend/src/cron_jobs/index.ts @@ -1,11 +1,11 @@ import {startDailyDigestJob} from "./daily-digest-job"; import {startNotificationsJob} from "./notifications-job"; import {startProjectDigestJob} from "./project-digest-job"; -import { startRecurringTasksJob } from "./recurring-tasks"; +import {startRecurringTasksJob} from "./recurring-tasks"; export function startCronJobs() { startNotificationsJob(); startDailyDigestJob(); startProjectDigestJob(); - // startRecurringTasksJob(); + startRecurringTasksJob(); } diff --git a/worklenz-backend/src/cron_jobs/recurring-tasks.ts b/worklenz-backend/src/cron_jobs/recurring-tasks.ts index a9ae7847..16854c7e 100644 --- a/worklenz-backend/src/cron_jobs/recurring-tasks.ts +++ b/worklenz-backend/src/cron_jobs/recurring-tasks.ts @@ -7,7 +7,7 @@ import TasksController from "../controllers/tasks-controller"; // At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday. // const TIME = "0 11 */1 * 1-5"; -const TIME = "*/2 * * * *"; +const TIME = "*/2 * * * *"; // runs every 2 minutes - for testing purposes const TIME_FORMAT = "YYYY-MM-DD"; // const TIME = "0 0 * * *"; // Runs at midnight every day diff --git a/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts b/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts new file mode 100644 index 00000000..6e19d7cb --- /dev/null +++ b/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts @@ -0,0 +1,16 @@ +import { API_BASE_URL } from "@/shared/constants"; +import { IServerResponse } from "@/types/common.types"; +import { ITaskRecurringSchedule } from "@/types/tasks/task-recurring-schedule"; +import apiClient from "../api-client"; + +const rootUrl = `${API_BASE_URL}/task-recurring`; + +export const taskRecurringApiService = { + getTaskRecurringData: async (schedule_id: string): Promise> => { + const response = await apiClient.get(`${rootUrl}/${schedule_id}`); + return response.data; + }, + updateTaskRecurringData: async (schedule_id: string, body: any): Promise> => { + return apiClient.put(`${rootUrl}/${schedule_id}`, body); + } +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx index 1e5af1d8..608fc321 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx @@ -15,21 +15,17 @@ import { import { SettingOutlined } from '@ant-design/icons'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; -import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; +import { IRepeatOption, ITaskRecurring, ITaskRecurringSchedule, ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; import { ITaskViewModel } from '@/types/tasks/task.types'; import { useTranslation } from 'react-i18next'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { updateRecurringChange } from '@/features/tasks/tasks.slice'; +import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service'; +import logger from '@/utils/errorLogger'; +import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice'; -const ITaskRecurring = { - Weekly: 'weekly', - EveryXDays: 'every_x_days', - EveryXWeeks: 'every_x_weeks', - EveryXMonths: 'every_x_months', - Monthly: 'monthly', -}; - -const repeatOptions = [ +const repeatOptions: IRepeatOption[] = [ + { label: 'Daily', value: ITaskRecurring.Daily }, { label: 'Weekly', value: ITaskRecurring.Weekly }, { label: 'Every X Days', value: ITaskRecurring.EveryXDays }, { label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks }, @@ -38,22 +34,22 @@ const repeatOptions = [ ]; const daysOfWeek = [ - { label: 'Mon', value: 'mon' }, - { label: 'Tue', value: 'tue' }, - { label: 'Wed', value: 'wed' }, - { label: 'Thu', value: 'thu' }, - { label: 'Fri', value: 'fri' }, - { label: 'Sat', value: 'sat' }, - { label: 'Sun', value: 'sun' }, + { label: 'Sunday', value: 0, checked: false }, + { label: 'Monday', value: 1, checked: false }, + { label: 'Tuesday', value: 2, checked: false }, + { label: 'Wednesday', value: 3, checked: false }, + { label: 'Thursday', value: 4, checked: false }, + { label: 'Friday', value: 5, checked: false }, + { label: 'Saturday', value: 6, checked: false } ]; -const monthlyDateOptions = Array.from({ length: 31 }, (_, i) => i + 1); +const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1); const weekOptions = [ - { label: 'First', value: 'first' }, - { label: 'Second', value: 'second' }, - { label: 'Third', value: 'third' }, - { label: 'Fourth', value: 'fourth' }, - { label: 'Last', value: 'last' }, + { label: 'First', value: 1 }, + { label: 'Second', value: 2 }, + { label: 'Third', value: 3 }, + { label: 'Fourth', value: 4 }, + { label: 'Last', value: 5 } ]; const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); @@ -64,7 +60,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const [recurring, setRecurring] = useState(false); const [showConfig, setShowConfig] = useState(false); - const [repeatOption, setRepeatOption] = useState(repeatOptions[0]); + const [repeatOption, setRepeatOption] = useState({}); const [selectedDays, setSelectedDays] = useState([]); const [monthlyOption, setMonthlyOption] = useState('date'); const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1); @@ -75,6 +71,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const [intervalMonths, setIntervalMonths] = useState(1); const [loadingData, setLoadingData] = useState(false); const [updatingData, setUpdatingData] = useState(false); + const [scheduleData, setScheduleData] = useState({}); const handleChange = (checked: boolean) => { if (!task.id) return; @@ -92,6 +89,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { if (selected) setRepeatOption(selected); } dispatch(updateRecurringChange(schedule)); + dispatch(setTaskRecurringSchedule({ schedule_id: schedule.id as string, task_id: task.id })); setRecurring(checked); if (!checked) setShowConfig(false); @@ -112,35 +110,119 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { setSelectedDays(checkedValues as unknown as string[]); }; - const handleSave = () => { - // Compose the schedule data and call the update handler - const data = { - recurring, - repeatOption, - selectedDays, - monthlyOption, - selectedMonthlyDate, - selectedMonthlyWeek, - selectedMonthlyDay, - intervalDays, - intervalWeeks, - intervalMonths, + const getSelectedDays = () => { + return daysOfWeek + .filter(day => day.checked) // Get only the checked days + .map(day => day.value); // Extract their numeric values + } + + const getUpdateBody = () => { + if (!task.id || !task.schedule_id || !repeatOption.value) return; + + const body: ITaskRecurringSchedule = { + id: task.id, + schedule_type: repeatOption.value }; - // if (onUpdateSchedule) onUpdateSchedule(data); - setShowConfig(false); + + switch (repeatOption.value) { + case ITaskRecurring.Weekly: + body.days_of_week = getSelectedDays(); + break; + + case ITaskRecurring.Monthly: + if (monthlyOption === 'date') { + body.date_of_month = selectedMonthlyDate; + setSelectedMonthlyDate(0); + setSelectedMonthlyDay(0); + } else { + body.week_of_month = selectedMonthlyWeek; + body.day_of_month = selectedMonthlyDay; + setSelectedMonthlyDate(0); + } + break; + + case ITaskRecurring.EveryXDays: + body.interval_days = intervalDays; + break; + + case ITaskRecurring.EveryXWeeks: + body.interval_weeks = intervalWeeks; + break; + + case ITaskRecurring.EveryXMonths: + body.interval_months = intervalMonths; + break; + } + return body; + } + + const handleSave = async () => { + if (!task.id || !task.schedule_id) return; + + try { + setUpdatingData(true); + const body = getUpdateBody(); + + const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body); + if (res.done) { + setShowConfig(false); + } + } catch (e) { + logger.error("handleSave", e); + } finally { + setUpdatingData(false); + } }; - const getScheduleData = () => {}; + const updateDaysOfWeek = () => { + for (let i = 0; i < daysOfWeek.length; i++) { + daysOfWeek[i].checked = scheduleData.days_of_week?.includes(daysOfWeek[i].value) ?? false; + } + }; + + const getScheduleData = async () => { + if (!task.schedule_id) return; + setLoadingData(true); + try { + const res = await taskRecurringApiService.getTaskRecurringData(task.schedule_id); + if (res.done) { + setScheduleData(res.body); + if (!res.body) { + setRepeatOption(repeatOptions[0]); + } else { + const selected = repeatOptions.find(e => e.value == res.body.schedule_type); + if (selected) { + setRepeatOption(selected); + setSelectedMonthlyDate(scheduleData.date_of_month || 1); + setSelectedMonthlyDay(scheduleData.day_of_month || 0); + setSelectedMonthlyWeek(scheduleData.week_of_month || 0); + setIntervalDays(scheduleData.interval_days || 1); + setIntervalWeeks(scheduleData.interval_weeks || 1); + setIntervalMonths(scheduleData.interval_months || 1); + setMonthlyOption(selectedMonthlyDate ? 'date' : 'day'); + updateDaysOfWeek(); + } + } + }; + } catch (e) { + logger.error("getScheduleData", e); + } + finally { + setLoadingData(false); + } + } const handleResponse = (response: ITaskRecurringScheduleData) => { if (!task || !response.task_id) return; }; useEffect(() => { + if (!task) return; + if (task) setRecurring(!!task.schedule_id); if (recurring) void getScheduleData(); socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse); - }, []); + }, [task]); return (
diff --git a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts index 9654a2d0..74ba350c 100644 --- a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts +++ b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts @@ -105,6 +105,15 @@ const taskDrawerSlice = createSlice({ }>) => { state.timeLogEditing = action.payload; }, + setTaskRecurringSchedule: (state, action: PayloadAction<{ + schedule_id: string; + task_id: string; + }>) => { + const { schedule_id, task_id } = action.payload; + if (state.taskFormViewModel?.task && state.taskFormViewModel.task.id === task_id) { + state.taskFormViewModel.task.schedule_id = schedule_id; + } + }, }, extraReducers: builder => { builder.addCase(fetchTask.pending, state => { @@ -133,5 +142,6 @@ export const { setTaskLabels, setTaskSubscribers, setTimeLogEditing, + setTaskRecurringSchedule } = taskDrawerSlice.actions; export default taskDrawerSlice.reducer; diff --git a/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts index 8fc708d5..190b6e7f 100644 --- a/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts +++ b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts @@ -1,19 +1,37 @@ +export enum ITaskRecurring { + Daily = 'daily', + Weekly = 'weekly', + Monthly = 'monthly', + EveryXDays = 'every_x_days', + EveryXWeeks = 'every_x_weeks', + EveryXMonths = 'every_x_months' +} + export interface ITaskRecurringSchedule { - type: 'daily' | 'weekly' | 'monthly' | 'interval'; - dayOfWeek?: number; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday (for weekly tasks) - dayOfMonth?: number; // 1 - 31 (for monthly tasks) - weekOfMonth?: number; // 1 = 1st week, 2 = 2nd week, ..., 5 = Last week (for monthly tasks) - hour: number; // Time of the day in 24-hour format - minute: number; // Minute of the hour - interval?: { - days?: number; // Interval in days (for every x days) - weeks?: number; // Interval in weeks (for every x weeks) - months?: number; // Interval in months (for every x months) - }; + created_at?: string; + day_of_month?: number | null; + date_of_month?: number | null; + days_of_week?: number[] | null; + id?: string; // UUID v4 + interval_days?: number | null; + interval_months?: number | null; + interval_weeks?: number | null; + schedule_type?: ITaskRecurring; + week_of_month?: number | null; +} + +export interface IRepeatOption { + value?: ITaskRecurring + label?: string } export interface ITaskRecurringScheduleData { - task_id?: string, - id?: string, - schedule_type?: string -} \ No newline at end of file + task_id?: string, + id?: string, + schedule_type?: string +} + +export interface IRepeatOption { + value?: ITaskRecurring + label?: string +} From 49bdd00dacbb258c2692f87894656d6f8879a62c Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 16 May 2025 14:45:47 +0530 Subject: [PATCH 06/35] fix(todo-list): update empty list image source to use relative path --- worklenz-frontend/src/pages/home/todo-list/todo-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx index 9fe5c59c..a27d2ac0 100644 --- a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx +++ b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx @@ -147,7 +147,7 @@ const TodoList = () => {
{data?.body.length === 0 ? ( ) : ( From f3a7fd8be5659f378e60a2a8d4494ac6cda4ae30 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Sun, 18 May 2025 20:15:40 +0530 Subject: [PATCH 07/35] refactor(project-view): optimize component with useMemo and useCallback for performance improvements - Introduced useMemo and useCallback to memoize tab menu items and callback functions, enhancing performance. - Added resetProjectData function to clean up project state on component unmount. - Refactored the component to use React.memo for preventing unnecessary re-renders. --- .../projects/projectView/project-view.tsx | 76 ++++++++----------- 1 file changed, 31 insertions(+), 45 deletions(-) diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index 91c1d636..d1ff8b9d 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { PushpinFilled, PushpinOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd'; import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; @@ -43,6 +43,14 @@ const ProjectView = () => { const [pinnedTab, setPinnedTab] = useState(searchParams.get('pinned_tab') || ''); const [taskid, setTaskId] = useState(searchParams.get('task') || ''); + const resetProjectData = useCallback(() => { + dispatch(setProjectId(null)); + dispatch(resetStatuses()); + dispatch(deselectAll()); + dispatch(resetTaskListData()); + dispatch(resetBoardData()); + }, [dispatch]); + useEffect(() => { if (projectId) { dispatch(setProjectId(projectId)); @@ -59,9 +67,13 @@ const ProjectView = () => { dispatch(setSelectedTaskId(taskid || '')); dispatch(setShowTaskDrawer(true)); } - }, [dispatch, navigate, projectId, taskid]); - const pinToDefaultTab = async (itemKey: string) => { + return () => { + resetProjectData(); + }; + }, [dispatch, navigate, projectId, taskid, resetProjectData]); + + const pinToDefaultTab = useCallback(async (itemKey: string) => { if (!itemKey || !projectId) return; const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD'; @@ -88,9 +100,9 @@ const ProjectView = () => { }).toString(), }); } - }; + }, [projectId, activeTab, navigate]); - const handleTabChange = (key: string) => { + const handleTabChange = useCallback((key: string) => { setActiveTab(key); dispatch(setProjectView(key === 'board' ? 'kanban' : 'list')); navigate({ @@ -100,9 +112,9 @@ const ProjectView = () => { pinned_tab: pinnedTab, }).toString(), }); - }; + }, [dispatch, location.pathname, navigate, pinnedTab]); - const tabMenuItems = tabItems.map(item => ({ + const tabMenuItems = useMemo(() => tabItems.map(item => ({ key: item.key, label: ( @@ -144,21 +156,17 @@ const ProjectView = () => { ), children: item.element, - })); + })), [pinnedTab, pinToDefaultTab]); - const resetProjectData = () => { - dispatch(setProjectId(null)); - dispatch(resetStatuses()); - dispatch(deselectAll()); - dispatch(resetTaskListData()); - dispatch(resetBoardData()); - }; - - useEffect(() => { - return () => { - resetProjectData(); - }; - }, []); + const portalElements = useMemo(() => ( + <> + {createPortal(, document.body, 'project-member-drawer')} + {createPortal(, document.body, 'phase-drawer')} + {createPortal(, document.body, 'status-drawer')} + {createPortal(, document.body, 'task-drawer')} + {createPortal(, document.body, 'delete-status-drawer')} + + ), []); return (
@@ -170,33 +178,11 @@ const ProjectView = () => { items={tabMenuItems} tabBarStyle={{ paddingInline: 0 }} destroyInactiveTabPane={true} - // tabBarExtraContent={ - //
- // - // - // - // - // - // - // - // - //
- // } /> - {createPortal(, document.body, 'project-member-drawer')} - {createPortal(, document.body, 'phase-drawer')} - {createPortal(, document.body, 'status-drawer')} - {createPortal(, document.body, 'task-drawer')} - {createPortal(, document.body, 'delete-status-drawer')} + {portalElements}
); }; -export default ProjectView; +export default React.memo(ProjectView); From f9858fbd4bcbe839aa257ba9232be7f0e1b02cce Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Sun, 18 May 2025 20:58:20 +0530 Subject: [PATCH 08/35] refactor(task-list): enhance performance with useMemo and useCallback - Introduced useMemo to optimize loading state and empty state calculations. - Added useMemo for socket event handler functions to prevent unnecessary re-renders. - Refactored data fetching logic to improve initial data load handling. - Improved drag-and-drop functionality with memoized handlers for better performance. --- .../taskList/project-view-task-list.tsx | 84 ++-- .../task-group-wrapper/task-group-wrapper.tsx | 395 +++++++++--------- 2 files changed, 250 insertions(+), 229 deletions(-) diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx index fcd4931a..410644fb 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import Flex from 'antd/es/flex'; import Skeleton from 'antd/es/skeleton'; import { useSearchParams } from 'react-router-dom'; @@ -17,8 +17,8 @@ const ProjectViewTaskList = () => { const dispatch = useAppDispatch(); const { projectView } = useTabSearchParam(); const [searchParams, setSearchParams] = useSearchParams(); - // Add local loading state to immediately show skeleton const [isLoading, setIsLoading] = useState(true); + const [initialLoadComplete, setInitialLoadComplete] = useState(false); const { projectId } = useAppSelector(state => state.projectReducer); const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector( @@ -30,47 +30,73 @@ const ProjectViewTaskList = () => { const { loadingPhases } = useAppSelector(state => state.phaseReducer); const { loadingColumns } = useAppSelector(state => state.taskReducer); + // Memoize the loading state calculation - ignoring task list filter loading + const isLoadingState = useMemo(() => + loadingGroups || loadingPhases || loadingStatusCategories, + [loadingGroups, loadingPhases, loadingStatusCategories] + ); + + // Memoize the empty state check + const isEmptyState = useMemo(() => + taskGroups && taskGroups.length === 0 && !isLoadingState, + [taskGroups, isLoadingState] + ); + + // Handle view type changes useEffect(() => { - // Set default view to list if projectView is not list or board if (projectView !== 'list' && projectView !== 'board') { - searchParams.set('tab', 'tasks-list'); - searchParams.set('pinned_tab', 'tasks-list'); - setSearchParams(searchParams); + const newParams = new URLSearchParams(searchParams); + newParams.set('tab', 'tasks-list'); + newParams.set('pinned_tab', 'tasks-list'); + setSearchParams(newParams); } - }, [projectView, searchParams, setSearchParams]); + }, [projectView, setSearchParams]); + // Update loading state useEffect(() => { - // Set loading state based on all loading conditions - setIsLoading(loadingGroups || loadingColumns || loadingPhases || loadingStatusCategories); - }, [loadingGroups, loadingColumns, loadingPhases, loadingStatusCategories]); + setIsLoading(isLoadingState); + }, [isLoadingState]); + // Fetch initial data only once useEffect(() => { - const loadData = async () => { - if (projectId && groupBy) { - const promises = []; - - if (!loadingColumns) promises.push(dispatch(fetchTaskListColumns(projectId))); - if (!loadingPhases) promises.push(dispatch(fetchPhasesByProjectId(projectId))); - if (!loadingGroups && projectView === 'list') { - promises.push(dispatch(fetchTaskGroups(projectId))); - } - if (!statusCategories.length) { - promises.push(dispatch(fetchStatusesCategories())); - } - - // Wait for all data to load - await Promise.all(promises); + const fetchInitialData = async () => { + if (!projectId || !groupBy || initialLoadComplete) return; + + try { + await Promise.all([ + dispatch(fetchTaskListColumns(projectId)), + dispatch(fetchPhasesByProjectId(projectId)), + dispatch(fetchStatusesCategories()) + ]); + setInitialLoadComplete(true); + } catch (error) { + console.error('Error fetching initial data:', error); } }; - - loadData(); - }, [dispatch, projectId, groupBy, fields, search, archived]); + + fetchInitialData(); + }, [projectId, groupBy, dispatch, initialLoadComplete]); + + // Fetch task groups + useEffect(() => { + const fetchTasks = async () => { + if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return; + + try { + await dispatch(fetchTaskGroups(projectId)); + } catch (error) { + console.error('Error fetching task groups:', error); + } + }; + + fetchTasks(); + }, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]); return ( - {(taskGroups && taskGroups.length === 0 && !isLoading) ? ( + {isEmptyState ? ( ) : ( diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx index f619f20a..6a0e9374 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx @@ -2,7 +2,7 @@ import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useSocket } from '@/socket/socketContext'; import { useAuthService } from '@/hooks/useAuth'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import Flex from 'antd/es/flex'; import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect'; @@ -87,16 +87,22 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees); const { projectId } = useAppSelector(state => state.projectReducer); - const sensors = useSensors( - useSensor(PointerSensor, { + // Move useSensors to top level and memoize its configuration + const sensorConfig = useMemo( + () => ({ activationConstraint: { distance: 8 }, - }) + }), + [] ); + const pointerSensor = useSensor(PointerSensor, sensorConfig); + const sensors = useSensors(pointerSensor); + useEffect(() => { setGroups(taskGroups); }, [taskGroups]); + // Memoize resetTaskRowStyles to prevent unnecessary re-renders const resetTaskRowStyles = useCallback(() => { document.querySelectorAll('.task-row').forEach(row => { row.style.transition = 'transform 0.2s ease, opacity 0.2s ease'; @@ -106,21 +112,18 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }); }, []); - // Socket handler for assignee updates - useEffect(() => { - if (!socket) return; - - const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => { + // Memoize socket event handlers + const handleAssigneesUpdate = useCallback( + (data: ITaskAssigneesUpdateResponse) => { if (!data) return; - const updatedAssignees = data.assignees.map(assignee => ({ + const updatedAssignees = data.assignees?.map(assignee => ({ ...assignee, selected: true, - })); + })) || []; - // Find the group that contains the task or its subtasks - const groupId = groups.find(group => - group.tasks.some( + const groupId = groups?.find(group => + group.tasks?.some( task => task.id === data.id || (task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id)) @@ -136,47 +139,41 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }) ); - dispatch(setTaskAssignee(data)); + dispatch( + setTaskAssignee({ + ...data, + manual_progress: false, + } as IProjectTask) + ); if (currentSession?.team_id && !loadingAssignees) { dispatch(fetchTaskAssignees(currentSession.team_id)); } } - }; + }, + [groups, dispatch, currentSession?.team_id, loadingAssignees] + ); - socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate); - return () => { - socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate); - }; - }, [socket, currentSession?.team_id, loadingAssignees, groups, dispatch]); - - // Socket handler for label updates - useEffect(() => { - if (!socket) return; - - const handleLabelsChange = async (labels: ILabelsChangeResponse) => { + // Memoize socket event handlers + const handleLabelsChange = useCallback( + async (labels: ILabelsChangeResponse) => { + if (!labels) return; + await Promise.all([ dispatch(updateTaskLabel(labels)), dispatch(setTaskLabels(labels)), dispatch(fetchLabels()), projectId && dispatch(fetchLabelsByProject(projectId)), ]); - }; + }, + [dispatch, projectId] + ); - socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange); - socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange); + // Memoize socket event handlers + const handleTaskStatusChange = useCallback( + (response: ITaskListStatusChangeResponse) => { + if (!response) return; - return () => { - socket.off(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange); - socket.off(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange); - }; - }, [socket, dispatch, projectId]); - - // Socket handler for status updates - useEffect(() => { - if (!socket) return; - - const handleTaskStatusChange = (response: ITaskListStatusChangeResponse) => { if (response.completed_deps === false) { alertService.error( 'Task is not completed', @@ -186,11 +183,14 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { } dispatch(updateTaskStatus(response)); - // dispatch(setTaskStatus(response)); dispatch(deselectAll()); - }; + }, + [dispatch] + ); - const handleTaskProgress = (data: { + // Memoize socket event handlers + const handleTaskProgress = useCallback( + (data: { id: string; status: string; complete_ratio: number; @@ -198,6 +198,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { total_tasks_count: number; parent_task: string; }) => { + if (!data) return; + dispatch( updateTaskProgress({ taskId: data.parent_task || data.id, @@ -206,190 +208,150 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { completedCount: data.completed_count, }) ); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange); - socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress); + // Memoize socket event handlers + const handlePriorityChange = useCallback( + (response: ITaskListPriorityChangeResponse) => { + if (!response) return; - return () => { - socket.off(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange); - socket.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress); - }; - }, [socket, dispatch]); - - // Socket handler for priority updates - useEffect(() => { - if (!socket) return; - - const handlePriorityChange = (response: ITaskListPriorityChangeResponse) => { dispatch(updateTaskPriority(response)); dispatch(setTaskPriority(response)); dispatch(deselectAll()); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange); - - return () => { - socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange); - }; - }, [socket, dispatch]); - - // Socket handler for due date updates - useEffect(() => { - if (!socket) return; - - const handleEndDateChange = (task: { + // Memoize socket event handlers + const handleEndDateChange = useCallback( + (task: { id: string; parent_task: string | null; end_date: string; }) => { - dispatch(updateTaskEndDate({ task })); - dispatch(setTaskEndDate(task)); - }; + if (!task) return; - socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange); + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; - return () => { - socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange); - }; - }, [socket, dispatch]); + dispatch(updateTaskEndDate({ task: taskWithProgress })); + dispatch(setTaskEndDate(taskWithProgress)); + }, + [dispatch] + ); - // Socket handler for task name updates - useEffect(() => { - if (!socket) return; + // Memoize socket event handlers + const handleTaskNameChange = useCallback( + (data: { id: string; parent_task: string; name: string }) => { + if (!data) return; - const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => { dispatch(updateTaskName(data)); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange); + // Memoize socket event handlers + const handlePhaseChange = useCallback( + (data: ITaskPhaseChangeResponse) => { + if (!data) return; - return () => { - socket.off(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange); - }; - }, [socket, dispatch]); - - // Socket handler for phase updates - useEffect(() => { - if (!socket) return; - - const handlePhaseChange = (data: ITaskPhaseChangeResponse) => { dispatch(updateTaskPhase(data)); dispatch(deselectAll()); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange); - - return () => { - socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange); - }; - }, [socket, dispatch]); - - // Socket handler for start date updates - useEffect(() => { - if (!socket) return; - - const handleStartDateChange = (task: { + // Memoize socket event handlers + const handleStartDateChange = useCallback( + (task: { id: string; parent_task: string | null; start_date: string; }) => { - dispatch(updateTaskStartDate({ task })); - dispatch(setStartDate(task)); - }; + if (!task) return; - socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange); + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; - return () => { - socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange); - }; - }, [socket, dispatch]); + dispatch(updateTaskStartDate({ task: taskWithProgress })); + dispatch(setStartDate(taskWithProgress)); + }, + [dispatch] + ); - // Socket handler for task subscribers updates - useEffect(() => { - if (!socket) return; + // Memoize socket event handlers + const handleTaskSubscribersChange = useCallback( + (data: InlineMember[]) => { + if (!data) return; - const handleTaskSubscribersChange = (data: InlineMember[]) => { dispatch(setTaskSubscribers(data)); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange); - - return () => { - socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange); - }; - }, [socket, dispatch]); - - // Socket handler for task estimation updates - useEffect(() => { - if (!socket) return; - - const handleEstimationChange = (task: { + // Memoize socket event handlers + const handleEstimationChange = useCallback( + (task: { id: string; parent_task: string | null; estimation: number; }) => { - dispatch(updateTaskEstimation({ task })); - }; + if (!task) return; - socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange); + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; - return () => { - socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange); - }; - }, [socket, dispatch]); + dispatch(updateTaskEstimation({ task: taskWithProgress })); + }, + [dispatch] + ); - // Socket handler for task description updates - useEffect(() => { - if (!socket) return; - - const handleTaskDescriptionChange = (data: { + // Memoize socket event handlers + const handleTaskDescriptionChange = useCallback( + (data: { id: string; parent_task: string; description: string; }) => { + if (!data) return; + dispatch(updateTaskDescription(data)); - }; + }, + [dispatch] + ); - socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange); - - return () => { - socket.off(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange); - }; - }, [socket, dispatch]); - - // Socket handler for new task creation - useEffect(() => { - if (!socket) return; - - const handleNewTaskReceived = (data: IProjectTask) => { + // Memoize socket event handlers + const handleNewTaskReceived = useCallback( + (data: IProjectTask) => { if (!data) return; if (data.parent_task_id) { dispatch(updateSubTasks(data)); } - }; + }, + [dispatch] + ); - socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived); - - return () => { - socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived); - }; - }, [socket, dispatch]); - - // Socket handler for task progress updates - useEffect(() => { - if (!socket) return; - - const handleTaskProgressUpdated = (data: { + // Memoize socket event handlers + const handleTaskProgressUpdated = useCallback( + (data: { task_id: string; progress_value?: number; weight?: number; }) => { + if (!data || !taskGroups) return; + if (data.progress_value !== undefined) { - // Find the task in the task groups and update its progress for (const group of taskGroups) { - const task = group.tasks.find(task => task.id === data.task_id); + const task = group.tasks?.find(task => task.id === data.task_id); if (task) { dispatch( updateTaskProgress({ @@ -403,25 +365,76 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { } } } + }, + [dispatch, taskGroups] + ); + + // Set up socket event listeners + useEffect(() => { + if (!socket) return; + + const eventHandlers = { + [SocketEvents.QUICK_ASSIGNEES_UPDATE.toString()]: handleAssigneesUpdate, + [SocketEvents.TASK_LABELS_CHANGE.toString()]: handleLabelsChange, + [SocketEvents.CREATE_LABEL.toString()]: handleLabelsChange, + [SocketEvents.TASK_STATUS_CHANGE.toString()]: handleTaskStatusChange, + [SocketEvents.GET_TASK_PROGRESS.toString()]: handleTaskProgress, + [SocketEvents.TASK_PRIORITY_CHANGE.toString()]: handlePriorityChange, + [SocketEvents.TASK_END_DATE_CHANGE.toString()]: handleEndDateChange, + [SocketEvents.TASK_NAME_CHANGE.toString()]: handleTaskNameChange, + [SocketEvents.TASK_PHASE_CHANGE.toString()]: handlePhaseChange, + [SocketEvents.TASK_START_DATE_CHANGE.toString()]: handleStartDateChange, + [SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString()]: handleTaskSubscribersChange, + [SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString()]: handleEstimationChange, + [SocketEvents.TASK_DESCRIPTION_CHANGE.toString()]: handleTaskDescriptionChange, + [SocketEvents.QUICK_TASK.toString()]: handleNewTaskReceived, + [SocketEvents.TASK_PROGRESS_UPDATED.toString()]: handleTaskProgressUpdated, }; - socket.on(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated); + // Register all event handlers + Object.entries(eventHandlers).forEach(([event, handler]) => { + if (handler) { + socket.on(event, handler); + } + }); + // Cleanup function return () => { - socket.off(SocketEvents.TASK_PROGRESS_UPDATED.toString(), handleTaskProgressUpdated); + Object.entries(eventHandlers).forEach(([event, handler]) => { + if (handler) { + socket.off(event, handler); + } + }); }; - }, [socket, dispatch, taskGroups]); + }, [ + socket, + handleAssigneesUpdate, + handleLabelsChange, + handleTaskStatusChange, + handleTaskProgress, + handlePriorityChange, + handleEndDateChange, + handleTaskNameChange, + handlePhaseChange, + handleStartDateChange, + handleTaskSubscribersChange, + handleEstimationChange, + handleTaskDescriptionChange, + handleNewTaskReceived, + handleTaskProgressUpdated, + ]); + // Memoize drag handlers const handleDragStart = useCallback(({ active }: DragStartEvent) => { setActiveId(active.id as string); - // Add smooth transition to the dragged item const draggedElement = document.querySelector(`[data-id="${active.id}"]`); if (draggedElement) { (draggedElement as HTMLElement).style.transition = 'transform 0.2s ease'; } }, []); + // Memoize drag handlers const handleDragEnd = useCallback( async ({ active, over }: DragEndEvent) => { setActiveId(null); @@ -440,10 +453,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId); if (fromIndex === -1) return; - // Create a deep clone of the task to avoid reference issues const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex])); - // Check if task dependencies allow the move if (activeGroupId !== overGroupId) { const canContinue = await checkTaskDependencyStatus(task.id, overGroupId); if (!canContinue) { @@ -455,7 +466,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { return; } - // Update task properties based on target group switch (groupBy) { case IGroupBy.STATUS: task.status = overGroupId; @@ -468,35 +478,29 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { task.priority_color_dark = targetGroup.color_code_dark; break; case IGroupBy.PHASE: - // Check if ALPHA_CHANNEL is already added const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL) - ? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) // Remove ALPHA_CHANNEL - : targetGroup.color_code; // Use as is if not present + ? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) + : targetGroup.color_code; task.phase_id = overGroupId; - task.phase_color = baseColor; // Set the cleaned color + task.phase_color = baseColor; break; } } const isTargetGroupEmpty = targetGroup.tasks.length === 0; - - // Calculate toIndex - for empty groups, always add at index 0 const toIndex = isTargetGroupEmpty ? 0 : overTaskId ? targetGroup.tasks.findIndex(t => t.id === overTaskId) : targetGroup.tasks.length; - // Calculate toPos similar to Angular implementation const toPos = isTargetGroupEmpty ? -1 : targetGroup.tasks[toIndex]?.sort_order || targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order || -1; - // Update Redux state if (activeGroupId === overGroupId) { - // Same group - move within array const updatedTasks = [...sourceGroup.tasks]; updatedTasks.splice(fromIndex, 1); updatedTasks.splice(toIndex, 0, task); @@ -514,7 +518,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }, }); } else { - // Different groups - transfer between arrays const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex); const updatedTargetTasks = [...targetGroup.tasks]; @@ -540,7 +543,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }); } - // Emit socket event socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { project_id: projectId, from_index: sourceGroup.tasks[fromIndex].sort_order, @@ -549,13 +551,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { from_group: sourceGroup.id, to_group: targetGroup.id, group_by: groupBy, - task: sourceGroup.tasks[fromIndex], // Send original task to maintain references + task: sourceGroup.tasks[fromIndex], team_id: currentSession?.team_id, }); - // Reset styles setTimeout(resetTaskRowStyles, 0); - trackMixpanelEvent(evt_project_task_list_drag_and_move); }, [ @@ -570,6 +570,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { ] ); + // Memoize drag handlers const handleDragOver = useCallback( ({ active, over }: DragEndEvent) => { if (!over) return; @@ -589,12 +590,9 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { if (fromIndex === -1 || toIndex === -1) return; - // Create a deep clone of the task to avoid reference issues const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex])); - // Update Redux state if (activeGroupId === overGroupId) { - // Same group - move within array const updatedTasks = [...sourceGroup.tasks]; updatedTasks.splice(fromIndex, 1); updatedTasks.splice(toIndex, 0, task); @@ -612,10 +610,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }, }); } else { - // Different groups - transfer between arrays const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex); const updatedTargetTasks = [...targetGroup.tasks]; - updatedTargetTasks.splice(toIndex, 0, task); dispatch({ @@ -663,7 +659,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { // Handle animation cleanup after drag ends useIsomorphicLayoutEffect(() => { if (activeId === null) { - // Final cleanup after React updates DOM const timeoutId = setTimeout(resetTaskRowStyles, 50); return () => clearTimeout(timeoutId); } From 69b910f2a43b64d9c71000ce170c771d81dc4cae Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Mon, 19 May 2025 06:28:03 +0530 Subject: [PATCH 09/35] refactor(sql-functions): enhance SQL functions with COALESCE for better null handling - Updated various SQL queries to use COALESCE, ensuring that null values are replaced with defaults for improved data integrity. - Modified the handling of schedule_id for recurring tasks to return a JSON object or 'null' as appropriate. - Improved the return structure of task-related JSON objects to prevent null values in the response. --- worklenz-backend/database/sql/4_functions.sql | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 189f8ac7..56bae8ba 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -3351,15 +3351,15 @@ BEGIN SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) FROM (SELECT team_member_id, project_member_id, - (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), - (SELECT email_notifications_enabled + COALESCE((SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), '') as name, + COALESCE((SELECT email_notifications_enabled FROM notification_settings WHERE team_id = tm.team_id - AND notification_settings.user_id = u.id) AS email_notifications_enabled, - u.avatar_url, + AND notification_settings.user_id = u.id), false) AS email_notifications_enabled, + COALESCE(u.avatar_url, '') as avatar_url, u.id AS user_id, - u.email, - u.socket_id AS socket_id, + COALESCE(u.email, '') as email, + COALESCE(u.socket_id, '') as socket_id, tm.team_id AS team_id FROM tasks_assignees INNER JOIN team_members tm ON tm.id = tasks_assignees.team_member_id @@ -4066,14 +4066,14 @@ DECLARE _schedule_id JSON; _task_completed_at TIMESTAMPTZ; BEGIN - SELECT name FROM tasks WHERE id = _task_id INTO _task_name; + SELECT COALESCE(name, '') FROM tasks WHERE id = _task_id INTO _task_name; - SELECT name + SELECT COALESCE(name, '') FROM task_statuses WHERE id = (SELECT status_id FROM tasks WHERE id = _task_id) INTO _previous_status_name; - SELECT name FROM task_statuses WHERE id = _status_id INTO _new_status_name; + SELECT COALESCE(name, '') FROM task_statuses WHERE id = _status_id INTO _new_status_name; IF (_previous_status_name != _new_status_name) THEN @@ -4081,14 +4081,22 @@ BEGIN SELECT get_task_complete_info(_task_id, _status_id) INTO _task_info; - SELECT name FROM users WHERE id = _user_id INTO _updater_name; + SELECT COALESCE(name, '') FROM users WHERE id = _user_id INTO _updater_name; _message = CONCAT(_updater_name, ' transitioned "', _task_name, '" from ', _previous_status_name, ' ⟶ ', _new_status_name); END IF; SELECT completed_at FROM tasks WHERE id = _task_id INTO _task_completed_at; - SELECT schedule_id FROM tasks WHERE id = _task_id INTO _schedule_id; + + -- Handle schedule_id properly for recurring tasks + SELECT CASE + WHEN schedule_id IS NULL THEN 'null'::json + ELSE json_build_object('id', schedule_id) + END + FROM tasks + WHERE id = _task_id + INTO _schedule_id; SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON) FROM (SELECT is_done, is_doing, is_todo @@ -4097,7 +4105,7 @@ BEGIN INTO _status_category; RETURN JSON_BUILD_OBJECT( - 'message', _message, + 'message', COALESCE(_message, ''), 'project_id', (SELECT project_id FROM tasks WHERE id = _task_id), 'parent_done', (CASE WHEN EXISTS(SELECT 1 @@ -4105,14 +4113,14 @@ BEGIN WHERE tasks_with_status_view.task_id = _task_id AND is_done IS TRUE) THEN 1 ELSE 0 END), - 'color_code', (_task_info ->> 'color_code')::TEXT, - 'color_code_dark', (_task_info ->> 'color_code_dark')::TEXT, - 'total_tasks', (_task_info ->> 'total_tasks')::INT, - 'total_completed', (_task_info ->> 'total_completed')::INT, - 'members', (_task_info ->> 'members')::JSON, + 'color_code', COALESCE((_task_info ->> 'color_code')::TEXT, ''), + 'color_code_dark', COALESCE((_task_info ->> 'color_code_dark')::TEXT, ''), + 'total_tasks', COALESCE((_task_info ->> 'total_tasks')::INT, 0), + 'total_completed', COALESCE((_task_info ->> 'total_completed')::INT, 0), + 'members', COALESCE((_task_info ->> 'members')::JSON, '[]'::JSON), 'completed_at', _task_completed_at, - 'status_category', _status_category, - 'schedule_id', _schedule_id + 'status_category', COALESCE(_status_category, '{}'::JSON), + 'schedule_id', COALESCE(_schedule_id, 'null'::JSON) ); END $$; From 82155cab8dc7dc753a3b560ee1b47d5874f85490 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 19 May 2025 10:57:43 +0530 Subject: [PATCH 10/35] docs: add user guide and cron job documentation for recurring tasks Add detailed documentation for recurring tasks, including a user guide explaining how to set up and manage recurring tasks, and a technical guide for the recurring tasks cron job. The user guide covers the purpose, setup process, and schedule options, while the technical guide explains the cron job's logic, database interactions, and configuration options. Additionally, include a migration script to fix ENUM type and casting issues for progress_mode_type. --- docs/recurring-tasks-user-guide.md | 39 +++++ docs/recurring-tasks.md | 56 ++++++ .../20250427000000-fix-progress-mode-type.sql | 160 ++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 docs/recurring-tasks-user-guide.md create mode 100644 docs/recurring-tasks.md create mode 100644 worklenz-backend/database/migrations/20250427000000-fix-progress-mode-type.sql diff --git a/docs/recurring-tasks-user-guide.md b/docs/recurring-tasks-user-guide.md new file mode 100644 index 00000000..98476b64 --- /dev/null +++ b/docs/recurring-tasks-user-guide.md @@ -0,0 +1,39 @@ +# Recurring Tasks: User Guide + +## What Are Recurring Tasks? +Recurring tasks are tasks that repeat automatically on a schedule you choose. This helps you save time and ensures important work is never forgotten. For example, you can set up a recurring task for weekly team meetings, monthly reports, or daily check-ins. + +## Why Use Recurring Tasks? +- **Save time:** No need to create the same task over and over. +- **Stay organized:** Tasks appear automatically when needed. +- **Never miss a deadline:** Tasks are created on time, every time. + +## How to Set Up a Recurring Task +1. Go to the tasks section in your workspace. +2. Choose to create a new task and look for the option to make it recurring. +3. Fill in the task details (name, description, assignees, etc.). +4. Select your preferred schedule (see options below). +5. Save the task. It will now be created automatically based on your chosen schedule. + +## Schedule Options +You can choose how often your task repeats. Here are the most common options: + +- **Daily:** The task is created every day. +- **Weekly:** The task is created once a week. You can pick the day (e.g., every Monday). +- **Monthly:** The task is created once a month. You can pick the date (e.g., the 1st of every month). +- **Weekdays:** The task is created every Monday to Friday. +- **Custom:** Set your own schedule, such as every 2 days, every 3 weeks, or only on certain days. + +### Examples +- "Send team update" every Friday (weekly) +- "Submit expense report" on the 1st of each month (monthly) +- "Check backups" every day (daily) +- "Review project status" every Monday and Thursday (custom) + +## Tips +- You can edit or stop a recurring task at any time. +- Assign team members and labels to recurring tasks for better organization. +- Check your task list regularly to see newly created recurring tasks. + +## Need Help? +If you have questions or need help setting up recurring tasks, contact your workspace admin or support team. \ No newline at end of file diff --git a/docs/recurring-tasks.md b/docs/recurring-tasks.md new file mode 100644 index 00000000..71fd51cc --- /dev/null +++ b/docs/recurring-tasks.md @@ -0,0 +1,56 @@ +# Recurring Tasks Cron Job Documentation + +## Overview +The recurring tasks cron job automates the creation of tasks based on predefined templates and schedules. It ensures that tasks are generated at the correct intervals without manual intervention, supporting efficient project management and timely task assignment. + +## Purpose +- Automatically create tasks according to recurring schedules defined in the database. +- Prevent duplicate task creation for the same schedule and date. +- Assign team members and labels to newly created tasks as specified in the template. + +## Scheduling Logic +- The cron job is scheduled using the [cron](https://www.npmjs.com/package/cron) package. +- The schedule is defined by a cron expression (e.g., `*/2 * * * *` for every 2 minutes, or `0 11 */1 * 1-5` for 11:00 UTC on weekdays). +- On each tick, the job: + 1. Fetches all recurring task templates and their schedules. + 2. Determines the next occurrence for each template using `calculateNextEndDate`. + 3. Checks if a task for the next occurrence already exists. + 4. Creates a new task if it does not exist and the next occurrence is within the allowed future window. + +## Database Interactions +- **Templates and Schedules:** + - Templates are stored in `task_recurring_templates`. + - Schedules are stored in `task_recurring_schedules`. + - The job joins these tables to get all necessary data for task creation. +- **Task Creation:** + - Uses a stored procedure `create_quick_task` to insert new tasks. + - Assigns team members and labels by calling appropriate functions/controllers. +- **State Tracking:** + - Updates `last_checked_at` and `last_created_task_end_date` in the schedule after processing. + +## Task Creation Process +1. **Fetch Templates:** Retrieve all templates and their associated schedules. +2. **Determine Next Occurrence:** Use the last task's end date or the schedule's creation date to calculate the next due date. +3. **Check for Existing Task:** Ensure no duplicate task is created for the same schedule and date. +4. **Create Task:** + - Insert the new task using the template's data. + - Assign team members and labels as specified. +5. **Update Schedule:** Record the last checked and created dates for accurate future runs. + +## Configuration & Extension Points +- **Cron Expression:** Modify the `TIME` constant in the code to change the schedule. +- **Task Template Structure:** Extend the template or schedule interfaces to support additional fields. +- **Task Creation Logic:** Customize the task creation process or add new assignment/labeling logic as needed. + +## Error Handling +- Errors are logged using the `log_error` utility. +- The job continues processing other templates even if one fails. + +## References +- Source: `src/cron_jobs/recurring-tasks.ts` +- Utilities: `src/shared/utils.ts` +- Database: `src/config/db.ts` +- Controllers: `src/controllers/tasks-controller.ts` + +--- +For further customization or troubleshooting, refer to the source code and update the documentation as needed. \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250427000000-fix-progress-mode-type.sql b/worklenz-backend/database/migrations/20250427000000-fix-progress-mode-type.sql new file mode 100644 index 00000000..557b1bc5 --- /dev/null +++ b/worklenz-backend/database/migrations/20250427000000-fix-progress-mode-type.sql @@ -0,0 +1,160 @@ +-- Migration: Fix progress_mode_type ENUM and casting issues +-- Date: 2025-04-27 +-- Version: 1.0.0 + +BEGIN; + +-- First, let's ensure the ENUM type exists with the correct values +DO $$ +BEGIN + -- Check if the type exists + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'progress_mode_type') THEN + CREATE TYPE progress_mode_type AS ENUM ('manual', 'weighted', 'time', 'default'); + ELSE + -- Add any missing values to the existing ENUM + BEGIN + ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'manual'; + ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'weighted'; + ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'time'; + ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'default'; + EXCEPTION + WHEN duplicate_object THEN + -- Ignore if values already exist + NULL; + END; + END IF; +END $$; + +-- Update functions to use proper type casting +CREATE OR REPLACE FUNCTION on_update_task_progress(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task_id UUID; + _progress_value INTEGER; + _parent_task_id UUID; + _project_id UUID; + _current_mode progress_mode_type; +BEGIN + _task_id = (_body ->> 'task_id')::UUID; + _progress_value = (_body ->> 'progress_value')::INTEGER; + _parent_task_id = (_body ->> 'parent_task_id')::UUID; + + -- Get the project ID and determine the current progress mode + SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id; + + IF _project_id IS NOT NULL THEN + SELECT + CASE + WHEN use_manual_progress IS TRUE THEN 'manual'::progress_mode_type + WHEN use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type + WHEN use_time_progress IS TRUE THEN 'time'::progress_mode_type + ELSE 'default'::progress_mode_type + END + INTO _current_mode + FROM projects + WHERE id = _project_id; + ELSE + _current_mode := 'default'::progress_mode_type; + END IF; + + -- Update the task with progress value and set the progress mode + UPDATE tasks + SET progress_value = _progress_value, + manual_progress = TRUE, + progress_mode = _current_mode, + updated_at = CURRENT_TIMESTAMP + WHERE id = _task_id; + + -- Return the updated task info + RETURN JSON_BUILD_OBJECT( + 'task_id', _task_id, + 'progress_value', _progress_value, + 'progress_mode', _current_mode + ); +END; +$$; + +-- Update the on_update_task_weight function to use proper type casting +CREATE OR REPLACE FUNCTION on_update_task_weight(_body json) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _task_id UUID; + _weight INTEGER; + _parent_task_id UUID; + _project_id UUID; +BEGIN + _task_id = (_body ->> 'task_id')::UUID; + _weight = (_body ->> 'weight')::INTEGER; + _parent_task_id = (_body ->> 'parent_task_id')::UUID; + + -- Get the project ID + SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id; + + -- Update the task with weight value and set progress_mode to 'weighted' + UPDATE tasks + SET weight = _weight, + progress_mode = 'weighted'::progress_mode_type, + updated_at = CURRENT_TIMESTAMP + WHERE id = _task_id; + + -- Return the updated task info + RETURN JSON_BUILD_OBJECT( + 'task_id', _task_id, + 'weight', _weight + ); +END; +$$; + +-- Update the reset_project_progress_values function to use proper type casting +CREATE OR REPLACE FUNCTION reset_project_progress_values() RETURNS TRIGGER + LANGUAGE plpgsql +AS +$$ +DECLARE + _old_mode progress_mode_type; + _new_mode progress_mode_type; + _project_id UUID; +BEGIN + _project_id := NEW.id; + + -- Determine old and new modes with proper type casting + _old_mode := + CASE + WHEN OLD.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type + WHEN OLD.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type + WHEN OLD.use_time_progress IS TRUE THEN 'time'::progress_mode_type + ELSE 'default'::progress_mode_type + END; + + _new_mode := + CASE + WHEN NEW.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type + WHEN NEW.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type + WHEN NEW.use_time_progress IS TRUE THEN 'time'::progress_mode_type + ELSE 'default'::progress_mode_type + END; + + -- If mode has changed, reset progress values for tasks with the old mode + IF _old_mode <> _new_mode THEN + -- Reset progress values for tasks that were set in the old mode + UPDATE tasks + SET progress_value = NULL, + progress_mode = NULL + WHERE project_id = _project_id + AND progress_mode = _old_mode; + END IF; + + RETURN NEW; +END; +$$; + +-- Update the tasks table to ensure proper type casting for existing values +UPDATE tasks +SET progress_mode = progress_mode::text::progress_mode_type +WHERE progress_mode IS NOT NULL; + +COMMIT; \ No newline at end of file From c19e06d902f650c572ec7fe83842cb83431bc001 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 19 May 2025 12:11:32 +0530 Subject: [PATCH 11/35] refactor: enhance task completion ratio calculation and reporting - Updated the `get_task_complete_ratio` function to improve handling of manual, weighted, and time-based progress calculations. - Added logic to ensure accurate task completion ratios, including checks for subtasks and project settings. - Enhanced error logging in the `refreshProjectTaskProgressValues` method for better debugging. - Introduced new fields in the reporting allocation controller to calculate and display total working hours and utilization metrics for team members. - Updated the frontend time sheet component to display utilization and over/under utilized hours in tooltips for better user insights. --- .../consolidated-progress-migrations.sql | 328 +++++++++++++----- .../reporting-allocation-controller.ts | 58 ++++ .../src/controllers/tasks-controller-v2.ts | 8 +- .../members-time-sheet/members-time-sheet.tsx | 16 + .../src/types/reporting/reporting.types.ts | 3 + 5 files changed, 329 insertions(+), 84 deletions(-) diff --git a/worklenz-backend/database/migrations/consolidated-progress-migrations.sql b/worklenz-backend/database/migrations/consolidated-progress-migrations.sql index c1007d24..ef89a923 100644 --- a/worklenz-backend/database/migrations/consolidated-progress-migrations.sql +++ b/worklenz-backend/database/migrations/consolidated-progress-migrations.sql @@ -23,33 +23,40 @@ ALTER TABLE projects ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE; -- Update function to consider manual progress -CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id UUID) RETURNS JSON +CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json LANGUAGE plpgsql AS $$ DECLARE - _parent_task_done FLOAT = 0; - _sub_tasks_done FLOAT = 0; - _sub_tasks_count FLOAT = 0; - _total_completed FLOAT = 0; - _total_tasks FLOAT = 0; - _ratio FLOAT = 0; - _is_manual BOOLEAN = FALSE; - _manual_value INTEGER = NULL; - _project_id UUID; - _use_manual_progress BOOLEAN = FALSE; + _parent_task_done FLOAT = 0; + _sub_tasks_done FLOAT = 0; + _sub_tasks_count FLOAT = 0; + _total_completed FLOAT = 0; + _total_tasks FLOAT = 0; + _ratio FLOAT = 0; + _is_manual BOOLEAN = FALSE; + _manual_value INTEGER = NULL; + _project_id UUID; + _use_manual_progress BOOLEAN = FALSE; _use_weighted_progress BOOLEAN = FALSE; - _use_time_progress BOOLEAN = FALSE; + _use_time_progress BOOLEAN = FALSE; + _task_complete BOOLEAN = FALSE; + _progress_mode VARCHAR(20) = NULL; BEGIN - -- Check if manual progress is set - SELECT manual_progress, progress_value, project_id + -- Check if manual progress is set for this task + SELECT manual_progress, progress_value, project_id, progress_mode, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = tasks.id + AND is_done IS TRUE + ) AS is_complete FROM tasks WHERE id = _task_id - INTO _is_manual, _manual_value, _project_id; + INTO _is_manual, _manual_value, _project_id, _progress_mode, _task_complete; -- Check if the project uses manual progress - IF _project_id IS NOT NULL - THEN + IF _project_id IS NOT NULL THEN SELECT COALESCE(use_manual_progress, FALSE), COALESCE(use_weighted_progress, FALSE), COALESCE(use_time_progress, FALSE) @@ -58,49 +65,212 @@ BEGIN INTO _use_manual_progress, _use_weighted_progress, _use_time_progress; END IF; - -- If manual progress is enabled and has a value, use it directly - IF _is_manual IS TRUE AND _manual_value IS NOT NULL - THEN + -- Get all subtasks + SELECT COUNT(*) + FROM tasks + WHERE parent_task_id = _task_id AND archived IS FALSE + INTO _sub_tasks_count; + + -- If task is complete, always return 100% + IF _task_complete IS TRUE THEN RETURN JSON_BUILD_OBJECT( + 'ratio', 100, + 'total_completed', 1, + 'total_tasks', 1, + 'is_manual', FALSE + ); + END IF; + + -- Determine current active mode + DECLARE + _current_mode VARCHAR(20) = CASE + WHEN _use_manual_progress IS TRUE THEN 'manual' + WHEN _use_weighted_progress IS TRUE THEN 'weighted' + WHEN _use_time_progress IS TRUE THEN 'time' + ELSE 'default' + END; + BEGIN + -- Only use manual progress value if it was set in the current active mode + -- and time progress is not enabled + IF _use_time_progress IS FALSE AND + ((_is_manual IS TRUE AND _manual_value IS NOT NULL AND + (_progress_mode IS NULL OR _progress_mode = _current_mode)) OR + (_use_manual_progress IS TRUE AND _manual_value IS NOT NULL AND + (_progress_mode IS NULL OR _progress_mode = 'manual'))) THEN + RETURN JSON_BUILD_OBJECT( 'ratio', _manual_value, 'total_completed', 0, 'total_tasks', 0, 'is_manual', TRUE - ); + ); + END IF; + END; + + -- If there are no subtasks, calculate based on the task itself + IF _sub_tasks_count = 0 THEN + -- Use time-based estimation if enabled + IF _use_time_progress IS TRUE THEN + -- Calculate progress based on logged time vs estimated time + WITH task_time_info AS ( + SELECT + COALESCE(t.total_minutes, 0) as estimated_minutes, + COALESCE(( + SELECT SUM(time_spent) + FROM task_work_log + WHERE task_id = t.id + ), 0) as logged_minutes + FROM tasks t + WHERE t.id = _task_id + ) + SELECT + CASE + WHEN _task_complete IS TRUE THEN 100 + WHEN estimated_minutes > 0 THEN + LEAST((logged_minutes / estimated_minutes) * 100, 100) + ELSE 0 + END + INTO _ratio + FROM task_time_info; + ELSE + -- Traditional calculation for non-time-based tasks + SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END) + INTO _parent_task_done; + + _ratio = _parent_task_done * 100; + END IF; + ELSE + -- If project uses manual progress, calculate based on subtask manual progress values + IF _use_manual_progress IS TRUE AND _use_time_progress IS FALSE THEN + WITH subtask_progress AS ( + SELECT + t.id, + t.manual_progress, + t.progress_value, + t.progress_mode, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ), + subtask_with_values AS ( + SELECT + CASE + WHEN is_complete IS TRUE THEN 100 + WHEN progress_value IS NOT NULL AND (progress_mode = 'manual' OR progress_mode IS NULL) THEN progress_value + ELSE 0 + END AS progress_value + FROM subtask_progress + ) + SELECT COALESCE(AVG(progress_value), 0) + FROM subtask_with_values + INTO _ratio; + -- If project uses weighted progress, calculate based on subtask weights + ELSIF _use_weighted_progress IS TRUE AND _use_time_progress IS FALSE THEN + WITH subtask_progress AS ( + SELECT + t.id, + t.manual_progress, + t.progress_value, + t.progress_mode, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete, + COALESCE(t.weight, 100) AS weight + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ), + subtask_with_values AS ( + SELECT + CASE + WHEN is_complete IS TRUE THEN 100 + WHEN progress_value IS NOT NULL AND (progress_mode = 'weighted' OR progress_mode IS NULL) THEN progress_value + ELSE 0 + END AS progress_value, + weight + FROM subtask_progress + ) + SELECT COALESCE( + SUM(progress_value * weight) / NULLIF(SUM(weight), 0), + 0 + ) + FROM subtask_with_values + INTO _ratio; + -- If project uses time-based progress, calculate based on actual logged time + ELSIF _use_time_progress IS TRUE THEN + WITH task_time_info AS ( + SELECT + t.id, + COALESCE(t.total_minutes, 0) as estimated_minutes, + COALESCE(( + SELECT SUM(time_spent) + FROM task_work_log + WHERE task_id = t.id + ), 0) as logged_minutes, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) + SELECT COALESCE( + SUM( + CASE + WHEN is_complete IS TRUE THEN estimated_minutes + ELSE LEAST(logged_minutes, estimated_minutes) + END + ) / NULLIF(SUM(estimated_minutes), 0) * 100, + 0 + ) + FROM task_time_info + INTO _ratio; + ELSE + -- Traditional calculation based on completion status + SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END) + INTO _parent_task_done; + + SELECT COUNT(*) + FROM tasks_with_status_view + WHERE parent_task_id = _task_id + AND is_done IS TRUE + INTO _sub_tasks_done; + + _total_completed = _parent_task_done + _sub_tasks_done; + _total_tasks = _sub_tasks_count + 1; -- +1 for the parent task + + IF _total_tasks = 0 THEN + _ratio = 0; + ELSE + _ratio = (_total_completed / _total_tasks) * 100; + END IF; + END IF; END IF; - -- Otherwise calculate automatically as before - SELECT (CASE - WHEN EXISTS(SELECT 1 - FROM tasks_with_status_view - WHERE tasks_with_status_view.task_id = _task_id - AND is_done IS TRUE) THEN 1 - ELSE 0 END) - INTO _parent_task_done; - SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id AND archived IS FALSE INTO _sub_tasks_count; - - SELECT COUNT(*) - FROM tasks_with_status_view - WHERE parent_task_id = _task_id - AND is_done IS TRUE - INTO _sub_tasks_done; - - _total_completed = _parent_task_done + _sub_tasks_done; - _total_tasks = _sub_tasks_count; -- +1 for the parent task - - IF _total_tasks > 0 - THEN - _ratio = (_total_completed / _total_tasks) * 100; - ELSE - _ratio = _parent_task_done * 100; + -- Ensure ratio is between 0 and 100 + IF _ratio < 0 THEN + _ratio = 0; + ELSIF _ratio > 100 THEN + _ratio = 100; END IF; RETURN JSON_BUILD_OBJECT( - 'ratio', _ratio, - 'total_completed', _total_completed, - 'total_tasks', _total_tasks, - 'is_manual', FALSE - ); + 'ratio', _ratio, + 'total_completed', _total_completed, + 'total_tasks', _total_tasks, + 'is_manual', _is_manual + ); END $$; @@ -615,38 +785,38 @@ BEGIN ) FROM subtask_with_values INTO _ratio; - -- If project uses time-based progress, calculate based on estimated time + -- If project uses time-based progress, calculate based on actual logged time ELSIF _use_time_progress IS TRUE THEN - WITH subtask_progress AS (SELECT t.id, - t.manual_progress, - t.progress_value, - t.progress_mode, - EXISTS(SELECT 1 - FROM tasks_with_status_view - WHERE tasks_with_status_view.task_id = t.id - AND is_done IS TRUE) AS is_complete, - COALESCE(t.total_minutes, 0) AS estimated_minutes - FROM tasks t - WHERE t.parent_task_id = _task_id - AND t.archived IS FALSE), - subtask_with_values AS (SELECT CASE - -- For completed tasks, always use 100% - WHEN is_complete IS TRUE THEN 100 - -- For tasks with progress value set in the correct mode, use it - WHEN progress_value IS NOT NULL AND - (progress_mode = 'time' OR progress_mode IS NULL) - THEN progress_value - -- Default to 0 for incomplete tasks with no progress value or wrong mode - ELSE 0 - END AS progress_value, - estimated_minutes - FROM subtask_progress) + WITH task_time_info AS ( + SELECT + t.id, + COALESCE(t.total_minutes, 0) as estimated_minutes, + COALESCE(( + SELECT SUM(time_spent) + FROM task_work_log + WHERE task_id = t.id + ), 0) as logged_minutes, + EXISTS( + SELECT 1 + FROM tasks_with_status_view + WHERE tasks_with_status_view.task_id = t.id + AND is_done IS TRUE + ) AS is_complete + FROM tasks t + WHERE t.parent_task_id = _task_id + AND t.archived IS FALSE + ) SELECT COALESCE( - SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0), - 0 - ) - FROM subtask_with_values + SUM( + CASE + WHEN is_complete IS TRUE THEN estimated_minutes + ELSE LEAST(logged_minutes, estimated_minutes) + END + ) / NULLIF(SUM(estimated_minutes), 0) * 100, + 0 + ) + FROM task_time_info INTO _ratio; ELSE -- Traditional calculation based on completion status diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts index be79c4b8..aee82dcd 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts @@ -408,6 +408,58 @@ export default class ReportingAllocationController extends ReportingControllerBa const { duration, date_range } = req.body; + // Calculate the date range (start and end) + let startDate: moment.Moment; + let endDate: moment.Moment; + if (date_range && date_range.length === 2) { + startDate = moment(date_range[0]); + endDate = moment(date_range[1]); + } else { + switch (duration) { + case DATE_RANGES.YESTERDAY: + startDate = moment().subtract(1, "day"); + endDate = moment().subtract(1, "day"); + break; + case DATE_RANGES.LAST_WEEK: + startDate = moment().subtract(1, "week").startOf("isoWeek"); + endDate = moment().subtract(1, "week").endOf("isoWeek"); + break; + case DATE_RANGES.LAST_MONTH: + startDate = moment().subtract(1, "month").startOf("month"); + endDate = moment().subtract(1, "month").endOf("month"); + break; + case DATE_RANGES.LAST_QUARTER: + startDate = moment().subtract(3, "months").startOf("quarter"); + endDate = moment().subtract(1, "quarter").endOf("quarter"); + break; + default: + startDate = moment().startOf("day"); + endDate = moment().endOf("day"); + } + } + + // Count only weekdays (Mon-Fri) in the period + let workingDays = 0; + let current = startDate.clone(); + while (current.isSameOrBefore(endDate, 'day')) { + const day = current.isoWeekday(); + if (day >= 1 && day <= 5) workingDays++; + current.add(1, 'day'); + } + + // Get hours_per_day for all selected projects + const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`; + const projectHoursResult = await db.query(projectHoursQuery, []); + const projectHoursMap: Record = {}; + for (const row of projectHoursResult.rows) { + projectHoursMap[row.id] = row.hours_per_day || 8; + } + // Sum total working hours for all selected projects + let totalWorkingHours = 0; + for (const pid of Object.keys(projectHoursMap)) { + totalWorkingHours += workingDays * projectHoursMap[pid]; + } + const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range); const archivedClause = archived ? "" @@ -430,6 +482,12 @@ export default class ReportingAllocationController extends ReportingControllerBa for (const member of result.rows) { member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0; member.color_code = getColor(member.name); + member.total_working_hours = totalWorkingHours; + member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00'; + member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00'; + // Over/under utilized hours: utilized_hours - total_working_hours + const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0; + member.over_under_utilized_hours = overUnder.toFixed(2); } return res.status(200).send(new ServerResponse(true, result.rows)); diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index c3231825..d6efa1bb 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -833,9 +833,7 @@ export default class TasksControllerV2 extends TasksControllerBase { } public static async refreshProjectTaskProgressValues(projectId: string): Promise { - try { - console.log(`Refreshing progress values for project ${projectId}`); - + try { // Run the recalculate_all_task_progress function only for tasks in this project const query = ` DO $$ @@ -893,10 +891,10 @@ export default class TasksControllerV2 extends TasksControllerBase { END $$; `; - const result = await db.query(query); + await db.query(query); console.log(`Finished refreshing progress values for project ${projectId}`); } catch (error) { - log_error('Error refreshing project task progress values', error); + log_error("Error refreshing project task progress values", error); } } diff --git a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx index f4c1bf89..7283a40a 100644 --- a/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx +++ b/worklenz-frontend/src/pages/reporting/time-reports/members-time-sheet/members-time-sheet.tsx @@ -81,6 +81,22 @@ const MembersTimeSheet = forwardRef((_, ref) => { display: false, position: 'top' as const, }, + tooltip: { + callbacks: { + label: function(context: any) { + const idx = context.dataIndex; + const member = jsonData[idx]; + const hours = member?.utilized_hours || '0.00'; + const percent = member?.utilization_percent || '0.00'; + const overUnder = member?.over_under_utilized_hours || '0.00'; + return [ + `${context.dataset.label}: ${hours} h`, + `Utilization: ${percent}%`, + `Over/Under Utilized: ${overUnder} h` + ]; + } + } + } }, backgroundColor: 'black', indexAxis: 'y' as const, diff --git a/worklenz-frontend/src/types/reporting/reporting.types.ts b/worklenz-frontend/src/types/reporting/reporting.types.ts index 91ad7392..aa36069c 100644 --- a/worklenz-frontend/src/types/reporting/reporting.types.ts +++ b/worklenz-frontend/src/types/reporting/reporting.types.ts @@ -406,6 +406,9 @@ export interface IRPTTimeMember { value?: number; color_code: string; logged_time?: string; + utilized_hours?: string; + utilization_percent?: string; + over_under_utilized_hours?: string; } export interface IMemberTaskStatGroupResonse { From fc30c1854e796f2e93b9e264f595c8b65b3519e6 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 19 May 2025 12:35:32 +0530 Subject: [PATCH 12/35] feat(reporting): add support for 'all time' date range in reporting allocation - Implemented logic to fetch the earliest start date from selected projects when the 'all time' duration is specified. - Updated the start date to default to January 1, 2000 if no valid date is found, ensuring robust date handling in reports. --- .../reporting/reporting-allocation-controller.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts index aee82dcd..4db8e3d5 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-allocation-controller.ts @@ -414,6 +414,13 @@ export default class ReportingAllocationController extends ReportingControllerBa if (date_range && date_range.length === 2) { startDate = moment(date_range[0]); endDate = moment(date_range[1]); + } else if (duration === DATE_RANGES.ALL_TIME) { + // Fetch the earliest start_date (or created_at if null) from selected projects + 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(minDate) : moment('2000-01-01'); + endDate = moment(); } else { switch (duration) { case DATE_RANGES.YESTERDAY: From 84c7428fed2646425d8a2f4cb8216ecc96646bc6 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 08:07:59 +0530 Subject: [PATCH 13/35] feat(recurring-tasks): enhance recurring task functionality and documentation - Expanded schedule options for recurring tasks, including new intervals for every X days, weeks, and months. - Added future task creation logic to ensure tasks are created within defined limits based on their schedule type. - Updated user guide to reflect new scheduling options and future task creation details. - Improved backend logic for recurring task creation, including batch processing and future limit calculations. - Added environment configuration for enabling recurring jobs. - Enhanced frontend localization for recurring task configuration labels. --- docs/recurring-tasks-user-guide.md | 35 +++- docs/recurring-tasks.md | 48 ++++++ worklenz-backend/.env.template | 6 +- .../src/controllers/tasks-controller-base.ts | 33 ++-- .../src/controllers/tasks-controller-v2.ts | 1 - .../src/cron_jobs/recurring-tasks.ts | 159 ++++++++++++------ .../task-drawer-recurring-config.json | 1 + .../task-drawer-recurring-config.json | 1 + .../task-drawer-recurring-config.json | 1 + .../task-drawer-recurring-config.tsx | 99 ++++++----- 10 files changed, 255 insertions(+), 129 deletions(-) diff --git a/docs/recurring-tasks-user-guide.md b/docs/recurring-tasks-user-guide.md index 98476b64..3d91572a 100644 --- a/docs/recurring-tasks-user-guide.md +++ b/docs/recurring-tasks-user-guide.md @@ -16,24 +16,45 @@ Recurring tasks are tasks that repeat automatically on a schedule you choose. Th 5. Save the task. It will now be created automatically based on your chosen schedule. ## Schedule Options -You can choose how often your task repeats. Here are the most common options: +You can choose how often your task repeats. Here are the available options: - **Daily:** The task is created every day. -- **Weekly:** The task is created once a week. You can pick the day (e.g., every Monday). -- **Monthly:** The task is created once a month. You can pick the date (e.g., the 1st of every month). -- **Weekdays:** The task is created every Monday to Friday. -- **Custom:** Set your own schedule, such as every 2 days, every 3 weeks, or only on certain days. +- **Weekly:** The task is created once a week. You can pick one or more days (e.g., every Monday and Thursday). +- **Monthly:** The task is created once a month. You have two options: + - **On a specific date:** Choose a date from 1 to 28 (limited to 28 to ensure consistency across all months) + - **On a specific day:** Choose a week (first, second, third, fourth, or last) and a day of the week +- **Every X Days:** The task is created every specified number of days (e.g., every 3 days) +- **Every X Weeks:** The task is created every specified number of weeks (e.g., every 2 weeks) +- **Every X Months:** The task is created every specified number of months (e.g., every 3 months) ### Examples - "Send team update" every Friday (weekly) -- "Submit expense report" on the 1st of each month (monthly) +- "Submit expense report" on the 15th of each month (monthly, specific date) +- "Monthly team meeting" on the first Monday of each month (monthly, specific day) - "Check backups" every day (daily) -- "Review project status" every Monday and Thursday (custom) +- "Review project status" every Monday and Thursday (weekly, multiple days) +- "Quarterly report" every 3 months (every X months) + +## Future Task Creation +The system automatically creates tasks up to a certain point in the future to ensure timely scheduling: + +- **Daily Tasks:** Created up to 7 days in advance +- **Weekly Tasks:** Created up to 2 weeks in advance +- **Monthly Tasks:** Created up to 2 months in advance +- **Every X Days/Weeks/Months:** Created up to 2 intervals in advance + +This ensures that: +- You always have upcoming tasks visible in your schedule +- Tasks are created at appropriate intervals +- The system maintains a reasonable number of future tasks ## Tips - You can edit or stop a recurring task at any time. - Assign team members and labels to recurring tasks for better organization. - Check your task list regularly to see newly created recurring tasks. +- For monthly tasks, dates are limited to 1-28 to ensure the task occurs on the same date every month. +- Tasks are created automatically within the future limit window - you don't need to manually create them. +- If you need to see tasks further in the future, they will be created automatically as the current tasks are completed. ## Need Help? If you have questions or need help setting up recurring tasks, contact your workspace admin or support team. \ No newline at end of file diff --git a/docs/recurring-tasks.md b/docs/recurring-tasks.md index 71fd51cc..71448719 100644 --- a/docs/recurring-tasks.md +++ b/docs/recurring-tasks.md @@ -17,6 +17,51 @@ The recurring tasks cron job automates the creation of tasks based on predefined 3. Checks if a task for the next occurrence already exists. 4. Creates a new task if it does not exist and the next occurrence is within the allowed future window. +## Future Limit Logic +The system implements different future limits based on the schedule type to maintain an appropriate number of future tasks: + +```typescript +const FUTURE_LIMITS = { + daily: moment.duration(7, 'days'), + weekly: moment.duration(2, 'weeks'), + monthly: moment.duration(2, 'months'), + every_x_days: (interval: number) => moment.duration(interval * 2, 'days'), + every_x_weeks: (interval: number) => moment.duration(interval * 2, 'weeks'), + every_x_months: (interval: number) => moment.duration(interval * 2, 'months') +}; +``` + +### Implementation Details +- **Base Calculation:** + ```typescript + const futureLimit = moment(template.last_checked_at || template.created_at) + .add(getFutureLimit(schedule.schedule_type, schedule.interval), 'days'); + ``` + +- **Task Creation Rules:** + 1. Only create tasks if the next occurrence is before the future limit + 2. Skip creation if a task already exists for that date + 3. Update `last_checked_at` after processing + +- **Benefits:** + - Prevents excessive task creation + - Maintains system performance + - Ensures timely task visibility + - Allows for schedule modifications + +## Date Handling +- **Monthly Tasks:** + - Dates are limited to 1-28 to ensure consistency across all months + - This prevents issues with months having different numbers of days + - No special handling needed for February or months with 30/31 days +- **Weekly Tasks:** + - Supports multiple days of the week (0-6, where 0 is Sunday) + - Tasks are created for each selected day +- **Interval-based Tasks:** + - Every X days/weeks/months from the last task's end date + - Minimum interval is 1 day/week/month + - No maximum limit, but tasks are only created up to the future limit + ## Database Interactions - **Templates and Schedules:** - Templates are stored in `task_recurring_templates`. @@ -27,6 +72,7 @@ The recurring tasks cron job automates the creation of tasks based on predefined - Assigns team members and labels by calling appropriate functions/controllers. - **State Tracking:** - Updates `last_checked_at` and `last_created_task_end_date` in the schedule after processing. + - Maintains future limits based on schedule type. ## Task Creation Process 1. **Fetch Templates:** Retrieve all templates and their associated schedules. @@ -41,10 +87,12 @@ The recurring tasks cron job automates the creation of tasks based on predefined - **Cron Expression:** Modify the `TIME` constant in the code to change the schedule. - **Task Template Structure:** Extend the template or schedule interfaces to support additional fields. - **Task Creation Logic:** Customize the task creation process or add new assignment/labeling logic as needed. +- **Future Window:** Adjust the future limits by modifying the `FUTURE_LIMITS` configuration. ## Error Handling - Errors are logged using the `log_error` utility. - The job continues processing other templates even if one fails. +- Failed task creations are not retried automatically. ## References - Source: `src/cron_jobs/recurring-tasks.ts` diff --git a/worklenz-backend/.env.template b/worklenz-backend/.env.template index e0bea264..fdd8fe44 100644 --- a/worklenz-backend/.env.template +++ b/worklenz-backend/.env.template @@ -78,4 +78,8 @@ GOOGLE_CAPTCHA_SECRET_KEY=your_captcha_secret_key GOOGLE_CAPTCHA_PASS_SCORE=0.8 # Email Cronjobs -ENABLE_EMAIL_CRONJOBS=true \ No newline at end of file +ENABLE_EMAIL_CRONJOBS=true + +# RECURRING_JOBS +ENABLE_RECURRING_JOBS=true +RECURRING_JOBS_INTERVAL="0 11 */1 * 1-5" \ No newline at end of file diff --git a/worklenz-backend/src/controllers/tasks-controller-base.ts b/worklenz-backend/src/controllers/tasks-controller-base.ts index 1fe89210..d2524bad 100644 --- a/worklenz-backend/src/controllers/tasks-controller-base.ts +++ b/worklenz-backend/src/controllers/tasks-controller-base.ts @@ -1,6 +1,6 @@ import WorklenzControllerBase from "./worklenz-controller-base"; -import {getColor} from "../shared/utils"; -import {PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants"; +import { getColor } from "../shared/utils"; +import { PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../shared/constants"; import moment from "moment/moment"; export const GroupBy = { @@ -32,23 +32,14 @@ export default class TasksControllerBase extends WorklenzControllerBase { } public static updateTaskViewModel(task: any) { - console.log(`Processing task ${task.id} (${task.name})`); - console.log(` manual_progress: ${task.manual_progress}, progress_value: ${task.progress_value}`); - console.log(` project_use_manual_progress: ${task.project_use_manual_progress}, project_use_weighted_progress: ${task.project_use_weighted_progress}`); - console.log(` has subtasks: ${task.sub_tasks_count > 0}`); - // For parent tasks (with subtasks), always use calculated progress from subtasks if (task.sub_tasks_count > 0) { - // For parent tasks without manual progress, calculate from subtasks (already done via db function) - console.log(` Parent task with subtasks: complete_ratio=${task.complete_ratio}`); - // Ensure progress matches complete_ratio for consistency task.progress = task.complete_ratio || 0; - + // Important: Parent tasks should not have manual progress // If they somehow do, reset it if (task.manual_progress) { - console.log(` WARNING: Parent task ${task.id} had manual_progress set to true, resetting`); task.manual_progress = false; task.progress_value = null; } @@ -58,28 +49,24 @@ export default class TasksControllerBase extends WorklenzControllerBase { // For manually set progress, use that value directly task.progress = parseInt(task.progress_value); task.complete_ratio = parseInt(task.progress_value); - - console.log(` Using manual progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`); - } + } // For tasks with no subtasks and no manual progress, calculate based on time else { - task.progress = task.total_minutes_spent && task.total_minutes - ? ~~(task.total_minutes_spent / task.total_minutes * 100) + task.progress = task.total_minutes_spent && task.total_minutes + ? ~~(task.total_minutes_spent / task.total_minutes * 100) : 0; - + // Set complete_ratio to match progress task.complete_ratio = task.progress; - - console.log(` Calculated time-based progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`); } - + // Ensure numeric values task.progress = parseInt(task.progress) || 0; task.complete_ratio = parseInt(task.complete_ratio) || 0; - + task.overdue = task.total_minutes < task.total_minutes_spent; - task.time_spent = {hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60}; + task.time_spent = { hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60 }; task.comments_count = Number(task.comments_count) ? +task.comments_count : 0; task.attachments_count = Number(task.attachments_count) ? +task.attachments_count : 0; diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index d6efa1bb..6e01c686 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -97,7 +97,6 @@ export default class TasksControllerV2 extends TasksControllerBase { try { const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]); const [data] = result.rows; - console.log("data", data); if (data && data.info && data.info.ratio !== undefined) { data.info.ratio = +((data.info.ratio || 0).toFixed()); return data.info; diff --git a/worklenz-backend/src/cron_jobs/recurring-tasks.ts b/worklenz-backend/src/cron_jobs/recurring-tasks.ts index 16854c7e..2780edd5 100644 --- a/worklenz-backend/src/cron_jobs/recurring-tasks.ts +++ b/worklenz-backend/src/cron_jobs/recurring-tasks.ts @@ -7,12 +7,90 @@ import TasksController from "../controllers/tasks-controller"; // At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday. // const TIME = "0 11 */1 * 1-5"; -const TIME = "*/2 * * * *"; // runs every 2 minutes - for testing purposes +const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 11 */1 * 1-5"; const TIME_FORMAT = "YYYY-MM-DD"; // const TIME = "0 0 * * *"; // Runs at midnight every day const log = (value: any) => console.log("recurring-task-cron-job:", value); +// Define future limits for different schedule types +// More conservative limits to prevent task list clutter +const FUTURE_LIMITS = { + daily: moment.duration(3, "days"), + weekly: moment.duration(1, "week"), + monthly: moment.duration(1, "month"), + every_x_days: (interval: number) => moment.duration(interval, "days"), + every_x_weeks: (interval: number) => moment.duration(interval, "weeks"), + every_x_months: (interval: number) => moment.duration(interval, "months") +}; + +// Helper function to get the future limit based on schedule type +function getFutureLimit(scheduleType: string, interval?: number): moment.Duration { + switch (scheduleType) { + case "daily": + return FUTURE_LIMITS.daily; + case "weekly": + return FUTURE_LIMITS.weekly; + case "monthly": + return FUTURE_LIMITS.monthly; + case "every_x_days": + return FUTURE_LIMITS.every_x_days(interval || 1); + case "every_x_weeks": + return FUTURE_LIMITS.every_x_weeks(interval || 1); + case "every_x_months": + return FUTURE_LIMITS.every_x_months(interval || 1); + default: + return moment.duration(3, "days"); // Default to 3 days + } +} + +// Helper function to batch create tasks +async function createBatchTasks(template: ITaskTemplate & IRecurringSchedule, endDates: moment.Moment[]) { + const createdTasks = []; + + for (const nextEndDate of endDates) { + const existingTaskQuery = ` + SELECT id FROM tasks + WHERE schedule_id = $1 AND end_date::DATE = $2::DATE; + `; + const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]); + + if (existingTaskResult.rows.length === 0) { + const createTaskQuery = `SELECT create_quick_task($1::json) as task;`; + const taskData = { + name: template.name, + priority_id: template.priority_id, + project_id: template.project_id, + reporter_id: template.reporter_id, + status_id: template.status_id || null, + end_date: nextEndDate.format(TIME_FORMAT), + schedule_id: template.schedule_id + }; + const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]); + const createdTask = createTaskResult.rows[0].task; + + if (createdTask) { + createdTasks.push(createdTask); + + for (const assignee of template.assignees) { + await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by); + } + + for (const label of template.labels) { + const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`; + await db.query(q, [createdTask.id, label.label_id]); + } + + console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`); + } + } else { + console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`); + } + } + + return createdTasks; +} + async function onRecurringTaskJobTick() { try { log("(cron) Recurring tasks job started."); @@ -33,65 +111,44 @@ async function onRecurringTaskJobTick() { ? moment(template.last_task_end_date) : moment(template.created_at); - const futureLimit = moment(template.last_checked_at || template.created_at).add(1, "week"); + // Calculate future limit based on schedule type + const futureLimit = moment(template.last_checked_at || template.created_at) + .add(getFutureLimit( + template.schedule_type, + template.interval_days || template.interval_weeks || template.interval_months || 1 + )); let nextEndDate = calculateNextEndDate(template, lastTaskEndDate); + const endDatesToCreate: moment.Moment[] = []; - // Find the next future occurrence - while (nextEndDate.isSameOrBefore(now)) { + // Find all future occurrences within the limit + while (nextEndDate.isSameOrBefore(futureLimit)) { + if (nextEndDate.isAfter(now)) { + endDatesToCreate.push(moment(nextEndDate)); + } nextEndDate = calculateNextEndDate(template, nextEndDate); } - // Only create a task if it's within the future limit - if (nextEndDate.isSameOrBefore(futureLimit)) { - const existingTaskQuery = ` - SELECT id FROM tasks - WHERE schedule_id = $1 AND end_date::DATE = $2::DATE; + // Batch create tasks for all future dates + if (endDatesToCreate.length > 0) { + const createdTasks = await createBatchTasks(template, endDatesToCreate); + createdTaskCount += createdTasks.length; + + // Update the last_checked_at in the schedule + const updateScheduleQuery = ` + UPDATE task_recurring_schedules + SET last_checked_at = $1::DATE, + last_created_task_end_date = $2 + WHERE id = $3; `; - const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]); - - if (existingTaskResult.rows.length === 0) { - const createTaskQuery = `SELECT create_quick_task($1::json) as task;`; - const taskData = { - name: template.name, - priority_id: template.priority_id, - project_id: template.project_id, - reporter_id: template.reporter_id, - status_id: template.status_id || null, - end_date: nextEndDate.format(TIME_FORMAT), - schedule_id: template.schedule_id - }; - const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]); - const createdTask = createTaskResult.rows[0].task; - - if (createdTask) { - createdTaskCount++; - - for (const assignee of template.assignees) { - await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by); - } - - for (const label of template.labels) { - const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`; - await db.query(q, [createdTask.id, label.label_id]); - } - - console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`); - } - } else { - console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`); - } + await db.query(updateScheduleQuery, [ + moment().format(TIME_FORMAT), + endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT), + template.schedule_id + ]); } else { - console.log(`No task created for template ${template.name} - next occurrence is beyond the future limit`); + console.log(`No tasks created for template ${template.name} - next occurrence is beyond the future limit`); } - - // Update the last_checked_at in the schedule - const updateScheduleQuery = ` - UPDATE task_recurring_schedules - SET last_checked_at = $1::DATE, last_created_task_end_date = $2 - WHERE id = $3; - `; - await db.query(updateScheduleQuery, [moment(template.last_checked_at || template.created_at).add(1, "day").format(TIME_FORMAT), nextEndDate.format(TIME_FORMAT), template.schedule_id]); } log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`); diff --git a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json index f1d0301d..10a9db71 100644 --- a/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json +++ b/worklenz-frontend/public/locales/en/task-drawer/task-drawer-recurring-config.json @@ -2,6 +2,7 @@ "recurring": "Recurring", "recurringTaskConfiguration": "Recurring task configuration", "repeats": "Repeats", + "daily": "Daily", "weekly": "Weekly", "everyXDays": "Every X Days", "everyXWeeks": "Every X Weeks", diff --git a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json index d9c711a5..ecc48c5f 100644 --- a/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json +++ b/worklenz-frontend/public/locales/es/task-drawer/task-drawer-recurring-config.json @@ -2,6 +2,7 @@ "recurring": "Recurrente", "recurringTaskConfiguration": "Configuración de tarea recurrente", "repeats": "Repeticiones", + "daily": "Diario", "weekly": "Semanal", "everyXDays": "Cada X días", "everyXWeeks": "Cada X semanas", diff --git a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json index 5619884b..d693f277 100644 --- a/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json +++ b/worklenz-frontend/public/locales/pt/task-drawer/task-drawer-recurring-config.json @@ -2,6 +2,7 @@ "recurring": "Recorrente", "recurringTaskConfiguration": "Configuração de tarefa recorrente", "repeats": "Repete", + "daily": "Diário", "weekly": "Semanal", "everyXDays": "A cada X dias", "everyXWeeks": "A cada X semanas", diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx index 608fc321..1ff8b315 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx @@ -24,44 +24,46 @@ import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service' import logger from '@/utils/errorLogger'; import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice'; -const repeatOptions: IRepeatOption[] = [ - { label: 'Daily', value: ITaskRecurring.Daily }, - { label: 'Weekly', value: ITaskRecurring.Weekly }, - { label: 'Every X Days', value: ITaskRecurring.EveryXDays }, - { label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks }, - { label: 'Every X Months', value: ITaskRecurring.EveryXMonths }, - { label: 'Monthly', value: ITaskRecurring.Monthly }, -]; - -const daysOfWeek = [ - { label: 'Sunday', value: 0, checked: false }, - { label: 'Monday', value: 1, checked: false }, - { label: 'Tuesday', value: 2, checked: false }, - { label: 'Wednesday', value: 3, checked: false }, - { label: 'Thursday', value: 4, checked: false }, - { label: 'Friday', value: 5, checked: false }, - { label: 'Saturday', value: 6, checked: false } -]; - const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1); -const weekOptions = [ - { label: 'First', value: 1 }, - { label: 'Second', value: 2 }, - { label: 'Third', value: 3 }, - { label: 'Fourth', value: 4 }, - { label: 'Last', value: 5 } -]; -const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const { socket, connected } = useSocket(); const dispatch = useAppDispatch(); const { t } = useTranslation('task-drawer/task-drawer-recurring-config'); + const repeatOptions: IRepeatOption[] = [ + { label: t('daily'), value: ITaskRecurring.Daily }, + { label: t('weekly'), value: ITaskRecurring.Weekly }, + { label: t('everyXDays'), value: ITaskRecurring.EveryXDays }, + { label: t('everyXWeeks'), value: ITaskRecurring.EveryXWeeks }, + { label: t('everyXMonths'), value: ITaskRecurring.EveryXMonths }, + { label: t('monthly'), value: ITaskRecurring.Monthly }, + ]; + + const daysOfWeek = [ + { label: t('sun'), value: 0, checked: false }, + { label: t('mon'), value: 1, checked: false }, + { label: t('tue'), value: 2, checked: false }, + { label: t('wed'), value: 3, checked: false }, + { label: t('thu'), value: 4, checked: false }, + { label: t('fri'), value: 5, checked: false }, + { label: t('sat'), value: 6, checked: false } + ]; + + const weekOptions = [ + { label: t('first'), value: 1 }, + { label: t('second'), value: 2 }, + { label: t('third'), value: 3 }, + { label: t('fourth'), value: 4 }, + { label: t('last'), value: 5 } + ]; + + const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); + const [recurring, setRecurring] = useState(false); const [showConfig, setShowConfig] = useState(false); const [repeatOption, setRepeatOption] = useState({}); - const [selectedDays, setSelectedDays] = useState([]); + const [selectedDays, setSelectedDays] = useState([]); const [monthlyOption, setMonthlyOption] = useState('date'); const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1); const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value); @@ -106,8 +108,8 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { [repeatOption] ); - const handleDayCheckboxChange = (checkedValues: string[]) => { - setSelectedDays(checkedValues as unknown as string[]); + const handleDayCheckboxChange = (checkedValues: number[]) => { + setSelectedDays(checkedValues); }; const getSelectedDays = () => { @@ -165,7 +167,9 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body); if (res.done) { + setRecurring(true); setShowConfig(false); + configVisibleChange(false); } } catch (e) { logger.error("handleSave", e); @@ -220,9 +224,9 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { if (!task) return; if (task) setRecurring(!!task.schedule_id); - if (recurring) void getScheduleData(); + if (task.schedule_id) void getScheduleData(); socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse); - }, [task]); + }, [task?.schedule_id]); return (
@@ -232,11 +236,11 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {   {recurring && (
- + { )} {monthlyOption === 'day' && ( <> - + { )} {repeatOption.value === ITaskRecurring.EveryXDays && ( - + { )} {repeatOption.value === ITaskRecurring.EveryXWeeks && ( - + { )} {repeatOption.value === ITaskRecurring.EveryXMonths && ( - + { loading={updatingData} onClick={handleSave} > - Save Changes + {t('saveChanges')} From cef4bffd691790e6c42bc19806f09ef6fe8033f9 Mon Sep 17 00:00:00 2001 From: Chamika J <75464293+chamikaJ@users.noreply.github.com> Date: Wed, 21 May 2025 08:28:09 +0530 Subject: [PATCH 14/35] Update README.md updated logo URL --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a6885d95..20dffb21 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- Worklenz Logo + Worklenz Logo
Worklenz From 0cb0efe43e50c06380fb13ad0242e86d41ecaa94 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 11:28:10 +0530 Subject: [PATCH 15/35] feat(cron-jobs): conditionally enable recurring tasks based on environment variable - Updated the cron job initialization to start recurring tasks only if the ENABLE_RECURRING_JOBS environment variable is set to "true". This allows for more flexible job management based on deployment configurations. --- worklenz-backend/src/cron_jobs/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worklenz-backend/src/cron_jobs/index.ts b/worklenz-backend/src/cron_jobs/index.ts index 20bd4f62..108a76f2 100644 --- a/worklenz-backend/src/cron_jobs/index.ts +++ b/worklenz-backend/src/cron_jobs/index.ts @@ -7,5 +7,5 @@ export function startCronJobs() { startNotificationsJob(); startDailyDigestJob(); startProjectDigestJob(); - startRecurringTasksJob(); + if (process.env.ENABLE_RECURRING_JOBS === "true") startRecurringTasksJob(); } From 2bdae400acee00ffbe851e270ccfb4e67ab8928c Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 11:42:42 +0530 Subject: [PATCH 16/35] feat(hubspot-integration): add HubSpot script component for production environment - Introduced a new HubSpot component that dynamically loads the HubSpot tracking script when in production. - Updated MainLayout to replace TawkTo with HubSpot for improved customer engagement tracking. --- worklenz-frontend/src/components/HubSpot.tsx | 24 ++++++++++++++++++++ worklenz-frontend/src/layouts/MainLayout.tsx | 6 ++--- 2 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 worklenz-frontend/src/components/HubSpot.tsx diff --git a/worklenz-frontend/src/components/HubSpot.tsx b/worklenz-frontend/src/components/HubSpot.tsx new file mode 100644 index 00000000..072ca433 --- /dev/null +++ b/worklenz-frontend/src/components/HubSpot.tsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; + +const HubSpot = () => { + useEffect(() => { + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.id = 'hs-script-loader'; + script.async = true; + script.defer = true; + script.src = '//js.hs-scripts.com/22348300.js'; + document.body.appendChild(script); + + return () => { + const existingScript = document.getElementById('hs-script-loader'); + if (existingScript) { + existingScript.remove(); + } + }; + }, []); + + return null; +}; + +export default HubSpot; \ No newline at end of file diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx index bbfd302b..d82073a1 100644 --- a/worklenz-frontend/src/layouts/MainLayout.tsx +++ b/worklenz-frontend/src/layouts/MainLayout.tsx @@ -7,7 +7,7 @@ import { colors } from '../styles/colors'; import { verifyAuthentication } from '@/features/auth/authSlice'; import { useEffect } from 'react'; import { useAppDispatch } from '@/hooks/useAppDispatch'; -import TawkTo from '@/components/TawkTo'; +import HubSpot from '@/components/HubSpot'; const MainLayout = () => { const themeMode = useAppSelector(state => state.themeReducer.mode); @@ -68,9 +68,7 @@ const MainLayout = () => { - {import.meta.env.VITE_APP_ENV === 'production' && ( - - )} + {import.meta.env.VITE_APP_ENV === 'production' && } ); From 4687478704da4dbb5762682a412776cf3eb3b11d Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 15:09:42 +0530 Subject: [PATCH 17/35] fix: update empty list image source to use S3 URL for consistency across components --- worklenz-frontend/src/components/EmptyListPlaceholder.tsx | 2 +- .../recent-and-favourite-project-list.tsx | 2 +- worklenz-frontend/src/pages/home/task-list/tasks-list.tsx | 2 +- worklenz-frontend/src/pages/home/todo-list/todo-list.tsx | 2 +- .../pages/projects/projectView/members/project-view-members.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/worklenz-frontend/src/components/EmptyListPlaceholder.tsx b/worklenz-frontend/src/components/EmptyListPlaceholder.tsx index 372cd845..dfe1aa76 100644 --- a/worklenz-frontend/src/components/EmptyListPlaceholder.tsx +++ b/worklenz-frontend/src/components/EmptyListPlaceholder.tsx @@ -8,7 +8,7 @@ type EmptyListPlaceholderProps = { }; const EmptyListPlaceholder = ({ - imageSrc = '/src/assets/images/empty-box.webp', + imageSrc = 'https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp', imageHeight = 60, text, }: EmptyListPlaceholderProps) => { diff --git a/worklenz-frontend/src/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list.tsx b/worklenz-frontend/src/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list.tsx index 27822e12..2102da02 100644 --- a/worklenz-frontend/src/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list.tsx +++ b/worklenz-frontend/src/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list.tsx @@ -132,7 +132,7 @@ const RecentAndFavouriteProjectList = () => {
{projectsData?.body?.length === 0 ? ( { ) : data?.body.total === 0 ? ( ) : ( diff --git a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx index a27d2ac0..f8715808 100644 --- a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx +++ b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx @@ -147,7 +147,7 @@ const TodoList = () => {
{data?.body.length === 0 ? ( ) : ( diff --git a/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx b/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx index 8b1b862f..2d669f73 100644 --- a/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/members/project-view-members.tsx @@ -263,7 +263,7 @@ const ProjectViewMembers = () => { > {members?.total === 0 ? ( From 8704b6a8c84014d45c4cc476a34ef816a4ae3bbd Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 15:45:50 +0530 Subject: [PATCH 18/35] style: adjust font-family formatting and add styles for HubSpot chat widget - Reformatted the font-family declaration for improved readability. - Added specific styles to prevent global styles from affecting the HubSpot chat widget, ensuring consistent appearance. --- worklenz-frontend/src/index.css | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/worklenz-frontend/src/index.css b/worklenz-frontend/src/index.css index bb0a0781..55f4c2f3 100644 --- a/worklenz-frontend/src/index.css +++ b/worklenz-frontend/src/index.css @@ -58,9 +58,9 @@ html.light body { margin: 0; padding: 0; box-sizing: border-box; - font-family: -apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial, - "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", - "Noto Color Emoji" !important; + font-family: + -apple-system, BlinkMacSystemFont, "Inter", Roboto, "Helvetica Neue", Arial, "Noto Sans", + sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !important; } /* helper classes */ @@ -145,3 +145,11 @@ Not supports in Firefox and IE */ tr:hover .action-buttons { opacity: 1; } + +/* Prevent global styles from affecting HubSpot chat widget */ +#hubspot-messages-iframe-container, +#hubspot-messages-iframe-container * { + background: none !important; + border-radius: 50% !important; + box-shadow: none !important; +} From d7ca1d8bd2454d591d17f80c58ab80597ae1f4a1 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 18:39:28 +0530 Subject: [PATCH 19/35] style: remove HubSpot chat widget styles from global CSS - Deleted specific styles that prevented global styles from affecting the HubSpot chat widget, streamlining the CSS file. --- worklenz-frontend/src/index.css | 7 ------- 1 file changed, 7 deletions(-) diff --git a/worklenz-frontend/src/index.css b/worklenz-frontend/src/index.css index 55f4c2f3..3c1af53d 100644 --- a/worklenz-frontend/src/index.css +++ b/worklenz-frontend/src/index.css @@ -146,10 +146,3 @@ tr:hover .action-buttons { opacity: 1; } -/* Prevent global styles from affecting HubSpot chat widget */ -#hubspot-messages-iframe-container, -#hubspot-messages-iframe-container * { - background: none !important; - border-radius: 50% !important; - box-shadow: none !important; -} From f716971654bb99b9331bc82ba9e9abdbee022fec Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 18:50:18 +0530 Subject: [PATCH 20/35] feat(hubspot-integration): dynamically load HubSpot script in production environment - Added a script to conditionally load the HubSpot tracking script in the index.html file when the hostname matches 'app.worklenz.com'. - Removed the HubSpot component from MainLayout to streamline the integration process. --- worklenz-frontend/index.html | 11 +++++++++++ worklenz-frontend/src/layouts/MainLayout.tsx | 1 - 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/worklenz-frontend/index.html b/worklenz-frontend/index.html index 57a2a1b0..ba93ca2c 100644 --- a/worklenz-frontend/index.html +++ b/worklenz-frontend/index.html @@ -48,6 +48,17 @@
+ \ No newline at end of file diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx index d82073a1..83a4f4c4 100644 --- a/worklenz-frontend/src/layouts/MainLayout.tsx +++ b/worklenz-frontend/src/layouts/MainLayout.tsx @@ -68,7 +68,6 @@ const MainLayout = () => { - {import.meta.env.VITE_APP_ENV === 'production' && } ); From c1e923c703736517f2a69d2ee1ba81ee6352e7ff Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Wed, 21 May 2025 21:42:16 +0530 Subject: [PATCH 21/35] feat(ownership-transfer): implement transfer_team_ownership function for team ownership management - Added a new PostgreSQL function to handle the transfer of team ownership between users --- worklenz-backend/database/sql/4_functions.sql | 216 ++++++++++ .../password-changed-notification.html | 286 +++++++------ .../reset-password.html | 344 ++++++++------- .../team-invitation.html | 343 ++++++++------- ...gistered-team-invitation-notification.html | 398 ++++++++++-------- .../worklenz-email-templates/welcome.html | 396 +++++++++-------- 6 files changed, 1129 insertions(+), 854 deletions(-) diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 56bae8ba..9c9cc820 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -6156,3 +6156,219 @@ BEGIN RETURN v_new_id; END; $$; + +CREATE OR REPLACE FUNCTION transfer_team_ownership(_team_id UUID, _new_owner_id UUID) RETURNS json + LANGUAGE plpgsql +AS +$$ +DECLARE + _old_owner_id UUID; + _owner_role_id UUID; + _admin_role_id UUID; + _old_org_id UUID; + _new_org_id UUID; + _has_license BOOLEAN; + _old_owner_role_id UUID; + _new_owner_role_id UUID; + _has_active_coupon BOOLEAN; + _other_teams_count INTEGER; + _new_owner_org_id UUID; + _license_type_id UUID; + _has_valid_license BOOLEAN; +BEGIN + -- Get the current owner's ID and organization + SELECT t.user_id, t.organization_id + INTO _old_owner_id, _old_org_id + FROM teams t + WHERE t.id = _team_id; + + IF _old_owner_id IS NULL THEN + RAISE EXCEPTION 'Team not found'; + END IF; + + -- Get the new owner's organization + SELECT organization_id INTO _new_owner_org_id + FROM organizations + WHERE user_id = _new_owner_id; + + -- Get the old organization + SELECT id INTO _old_org_id + FROM organizations + WHERE id = _old_org_id; + + IF _old_org_id IS NULL THEN + RAISE EXCEPTION 'Organization not found'; + END IF; + + -- Check if new owner has any valid license type + SELECT EXISTS ( + SELECT 1 + FROM ( + -- Check regular subscriptions + SELECT lus.user_id, lus.status, lus.active + FROM licensing_user_subscriptions lus + WHERE lus.user_id = _new_owner_id + AND lus.active = TRUE + AND lus.status IN ('active', 'trialing') + + UNION ALL + + -- Check custom subscriptions + SELECT lcs.user_id, lcs.subscription_status as status, TRUE as active + FROM licensing_custom_subs lcs + WHERE lcs.user_id = _new_owner_id + AND lcs.end_date > CURRENT_DATE + + UNION ALL + + -- Check trial status in organizations + SELECT o.user_id, o.subscription_status as status, TRUE as active + FROM organizations o + WHERE o.user_id = _new_owner_id + AND o.trial_in_progress = TRUE + AND o.trial_expire_date > CURRENT_DATE + ) valid_licenses + ) INTO _has_valid_license; + + IF NOT _has_valid_license THEN + RAISE EXCEPTION 'New owner does not have a valid license (subscription, custom subscription, or trial)'; + END IF; + + -- Check if new owner has any active coupon codes + SELECT EXISTS ( + SELECT 1 + FROM licensing_coupon_codes lcc + WHERE lcc.redeemed_by = _new_owner_id + AND lcc.is_redeemed = TRUE + AND lcc.is_refunded = FALSE + ) INTO _has_active_coupon; + + IF _has_active_coupon THEN + RAISE EXCEPTION 'New owner has active coupon codes that need to be handled before transfer'; + END IF; + + -- Count other teams in the organization for information purposes + SELECT COUNT(*) INTO _other_teams_count + FROM teams + WHERE organization_id = _old_org_id + AND id != _team_id; + + -- If new owner has their own organization, move the team to their organization + IF _new_owner_org_id IS NOT NULL THEN + -- Update the team to use the new owner's organization + UPDATE teams + SET user_id = _new_owner_id, + organization_id = _new_owner_org_id + WHERE id = _team_id; + + -- Create notification about organization change + PERFORM create_notification( + _old_owner_id, + _team_id, + NULL, + NULL, + CONCAT('Team ', (SELECT name FROM teams WHERE id = _team_id), ' has been moved to a different organization') + ); + + PERFORM create_notification( + _new_owner_id, + _team_id, + NULL, + NULL, + CONCAT('Team ', (SELECT name FROM teams WHERE id = _team_id), ' has been moved to your organization') + ); + ELSE + -- If new owner doesn't have an organization, transfer the old organization to them + UPDATE organizations + SET user_id = _new_owner_id + WHERE id = _old_org_id; + + -- Update the team to use the same organization + UPDATE teams + SET user_id = _new_owner_id, + organization_id = _old_org_id + WHERE id = _team_id; + + -- Notify both users about organization ownership transfer + PERFORM create_notification( + _old_owner_id, + NULL, + NULL, + NULL, + CONCAT('You are no longer the owner of organization ', (SELECT organization_name FROM organizations WHERE id = _old_org_id), '') + ); + + PERFORM create_notification( + _new_owner_id, + NULL, + NULL, + NULL, + CONCAT('You are now the owner of organization ', (SELECT organization_name FROM organizations WHERE id = _old_org_id), '') + ); + END IF; + + -- Get the owner and admin role IDs + SELECT id INTO _owner_role_id FROM roles WHERE team_id = _team_id AND owner = TRUE; + SELECT id INTO _admin_role_id FROM roles WHERE team_id = _team_id AND admin_role = TRUE; + + -- Get current role IDs for both users + SELECT role_id INTO _old_owner_role_id + FROM team_members + WHERE team_id = _team_id AND user_id = _old_owner_id; + + SELECT role_id INTO _new_owner_role_id + FROM team_members + WHERE team_id = _team_id AND user_id = _new_owner_id; + + -- Update the old owner's role to admin if they want to stay in the team + IF _old_owner_role_id IS NOT NULL THEN + UPDATE team_members + SET role_id = _admin_role_id + WHERE team_id = _team_id AND user_id = _old_owner_id; + END IF; + + -- Update the new owner's role to owner + IF _new_owner_role_id IS NOT NULL THEN + UPDATE team_members + SET role_id = _owner_role_id + WHERE team_id = _team_id AND user_id = _new_owner_id; + ELSE + -- If new owner is not a team member yet, add them + INSERT INTO team_members (user_id, team_id, role_id) + VALUES (_new_owner_id, _team_id, _owner_role_id); + END IF; + + -- Create notification for both users about team ownership + PERFORM create_notification( + _old_owner_id, + _team_id, + NULL, + NULL, + CONCAT('You are no longer the owner of team ', (SELECT name FROM teams WHERE id = _team_id), '') + ); + + PERFORM create_notification( + _new_owner_id, + _team_id, + NULL, + NULL, + CONCAT('You are now the owner of team ', (SELECT name FROM teams WHERE id = _team_id), '') + ); + + RETURN json_build_object( + 'success', TRUE, + 'old_owner_id', _old_owner_id, + 'new_owner_id', _new_owner_id, + 'team_id', _team_id, + 'old_org_id', _old_org_id, + 'new_org_id', COALESCE(_new_owner_org_id, _old_org_id), + 'old_role_id', _old_owner_role_id, + 'new_role_id', _new_owner_role_id, + 'has_valid_license', _has_valid_license, + 'has_active_coupon', _has_active_coupon, + 'other_teams_count', _other_teams_count, + 'org_ownership_transferred', _new_owner_org_id IS NULL, + 'team_moved_to_new_org', _new_owner_org_id IS NOT NULL + ); +END; +$$; diff --git a/worklenz-backend/worklenz-email-templates/password-changed-notification.html b/worklenz-backend/worklenz-email-templates/password-changed-notification.html index f734d8a8..2c8e2d3a 100644 --- a/worklenz-backend/worklenz-email-templates/password-changed-notification.html +++ b/worklenz-backend/worklenz-email-templates/password-changed-notification.html @@ -2,31 +2,30 @@ - + Password Changed | Worklenz + - - - - + + + + + + + + +
diff --git a/worklenz-backend/worklenz-email-templates/reset-password.html b/worklenz-backend/worklenz-email-templates/reset-password.html index d6f7e4d7..9c5f2c24 100644 --- a/worklenz-backend/worklenz-email-templates/reset-password.html +++ b/worklenz-backend/worklenz-email-templates/reset-password.html @@ -2,31 +2,30 @@ - + Reset Your Password | Worklenz + - - - - - - - - + + + +
diff --git a/worklenz-backend/worklenz-email-templates/team-invitation.html b/worklenz-backend/worklenz-email-templates/team-invitation.html index 921e845d..f0d17e33 100644 --- a/worklenz-backend/worklenz-email-templates/team-invitation.html +++ b/worklenz-backend/worklenz-email-templates/team-invitation.html @@ -2,31 +2,30 @@ - + Join Your Team on Worklenz + - - - - - - - - + + + +

diff --git a/worklenz-backend/worklenz-email-templates/unregistered-team-invitation-notification.html b/worklenz-backend/worklenz-email-templates/unregistered-team-invitation-notification.html index a231f9ad..2db5cfc2 100644 --- a/worklenz-backend/worklenz-email-templates/unregistered-team-invitation-notification.html +++ b/worklenz-backend/worklenz-email-templates/unregistered-team-invitation-notification.html @@ -2,31 +2,30 @@ - + Join Your Team on Worklenz + - - - - - + + + + + + + + + + + + + + + +
diff --git a/worklenz-backend/worklenz-email-templates/welcome.html b/worklenz-backend/worklenz-email-templates/welcome.html index bc258a6d..7bb62821 100644 --- a/worklenz-backend/worklenz-email-templates/welcome.html +++ b/worklenz-backend/worklenz-email-templates/welcome.html @@ -2,31 +2,30 @@ - + Welcome to Worklenz + - - - - - + + + + + + + + + + + + + + + +
From c18889a127d1965845ac3edc36fede3bef97ff3f Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Thu, 22 May 2025 09:39:53 +0530 Subject: [PATCH 22/35] refactor(task-drawer): remove unused imports and add edit mode for task name Removed unused `useEffect` import in `task-drawer-status-dropdown.tsx` and unused `connected` variable. Added edit mode for task name in `task-drawer-header.tsx` to improve user interaction by allowing inline editing of the task name. --- .../task-drawer-header/task-drawer-header.tsx | 50 +++++++++++++------ .../task-drawer-status-dropdown.tsx | 4 +- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx index 6a20f0b9..0bc322f3 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer-header/task-drawer-header.tsx @@ -27,6 +27,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { const { socket, connected } = useSocket(); const { clearTaskFromUrl } = useTaskDrawerUrlSync(); const isDeleting = useRef(false); + const [isEditing, setIsEditing] = useState(false); const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer); const [taskName, setTaskName] = useState(taskFormViewModel?.task?.name ?? ''); @@ -88,6 +89,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { }; const handleInputBlur = () => { + setIsEditing(false); if ( !selectedTaskId || !connected || @@ -113,21 +115,39 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => { return ( - onTaskNameChange(e)} - onBlur={handleInputBlur} - placeholder={t('taskHeader.taskNamePlaceholder')} - className="task-name-input" - style={{ - width: '100%', - border: 'none', - }} - showCount={false} - maxLength={250} - /> + {isEditing ? ( + onTaskNameChange(e)} + onBlur={handleInputBlur} + placeholder={t('taskHeader.taskNamePlaceholder')} + className="task-name-input" + style={{ + width: '100%', + border: 'none', + }} + showCount={true} + maxLength={250} + autoFocus + /> + ) : ( +

setIsEditing(true)} + style={{ + margin: 0, + padding: '4px 11px', + fontSize: '16px', + cursor: 'pointer', + wordWrap: 'break-word', + overflowWrap: 'break-word', + width: '100%' + }} + > + {taskName || t('taskHeader.taskNamePlaceholder')} +

+ )}
{ - const { socket, connected } = useSocket(); + const { socket } = useSocket(); const dispatch = useAppDispatch(); const themeMode = useAppSelector(state => state.themeReducer.mode); const { tab } = useTabSearchParam(); From 32248f8424a81c5ae71278ccf4dcce2116527161 Mon Sep 17 00:00:00 2001 From: kithmina1999 Date: Wed, 28 May 2025 09:32:32 +0530 Subject: [PATCH 23/35] Update README.md to include video guides for local and remote deployment - Added a section for a video guide on local Docker deployment. - Included a video guide for deploying Worklenz to a remote server. --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index a6885d95..1805e6a3 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,7 @@ docker-compose up -d docker-compose down ``` + ## MinIO Integration The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production. @@ -403,6 +404,10 @@ This script generates properly configured environment files for both development - Frontend: http://localhost:5000 - Backend API: http://localhost:3000 (or https://localhost:3000 with SSL) +4. Video Guide + + For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg). + ### Remote Server Deployment When deploying to a remote server: @@ -428,6 +433,10 @@ When deploying to a remote server: - Frontend: http://your-server-hostname:5000 - Backend API: http://your-server-hostname:3000 +4. Video Guide + + For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s). + ### Environment Configuration The Docker setup uses environment variables to configure the services: From 102be2c24aea234e27b5ade54fdb94371fa30a0d Mon Sep 17 00:00:00 2001 From: "Gabriel A. Devenyi" Date: Tue, 27 May 2025 22:40:19 -0400 Subject: [PATCH 24/35] Generate random passwords in update-docker-env.sh --- update-docker-env.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/update-docker-env.sh b/update-docker-env.sh index 77ab1beb..12044bd1 100755 --- a/update-docker-env.sh +++ b/update-docker-env.sh @@ -73,8 +73,8 @@ cat > worklenz-backend/.env << EOL NODE_ENV=production PORT=3000 SESSION_NAME=worklenz.sid -SESSION_SECRET=change_me_in_production -COOKIE_SECRET=change_me_in_production +SESSION_SECRET=$(openssl rand -base64 48) +COOKIE_SECRET=$(openssl rand -base64 48) # CORS SOCKET_IO_CORS=${FRONTEND_URL} @@ -92,7 +92,7 @@ LOGIN_SUCCESS_REDIRECT="${FRONTEND_URL}/auth/authenticating" DB_HOST=db DB_PORT=5432 DB_USER=postgres -DB_PASSWORD=password +DB_PASSWORD=$(openssl rand -base64 48) DB_NAME=worklenz_db DB_MAX_CLIENTS=50 USE_PG_NATIVE=true @@ -123,7 +123,7 @@ SLACK_WEBHOOK= COMMIT_BUILD_IMMEDIATELY=true # JWT Secret -JWT_SECRET=change_me_in_production +JWT_SECRET=$(openssl rand -base64 48) EOL echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS") @@ -138,4 +138,4 @@ echo "Frontend URL: ${FRONTEND_URL}" echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000" echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000" echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}" -echo "CORS is configured to allow requests from: ${FRONTEND_URL}" \ No newline at end of file +echo "CORS is configured to allow requests from: ${FRONTEND_URL}" From 65af5f659eac0c9a4e5acc308eb666ba4162338e Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 30 May 2025 10:56:19 +0530 Subject: [PATCH 25/35] refactor(build): remove Gruntfile and transition to npm scripts for build process - Deleted Gruntfile.js to streamline the build process. - Updated package.json to include new npm scripts for build, clean, and watch tasks. - Added dependencies for concurrent execution and CSRF token management. - Integrated csrf-sync for improved CSRF protection in the application. - Refactored app and API client to utilize the new CSRF token management approach. --- worklenz-backend/Gruntfile.js | 131 - worklenz-backend/package-lock.json | 2443 ++++++----------- worklenz-backend/package.json | 43 +- worklenz-backend/scripts/compress.js | 53 + worklenz-backend/src/app.ts | 53 +- worklenz-frontend/src/App.tsx | 8 + worklenz-frontend/src/api/api-client.ts | 45 +- .../api/home-page/home-page.api.service.ts | 15 +- .../api/projects/projects.v1.api.service.ts | 15 +- 9 files changed, 982 insertions(+), 1824 deletions(-) delete mode 100644 worklenz-backend/Gruntfile.js create mode 100644 worklenz-backend/scripts/compress.js diff --git a/worklenz-backend/Gruntfile.js b/worklenz-backend/Gruntfile.js deleted file mode 100644 index b621cbc0..00000000 --- a/worklenz-backend/Gruntfile.js +++ /dev/null @@ -1,131 +0,0 @@ -module.exports = function (grunt) { - - // Project configuration. - grunt.initConfig({ - pkg: grunt.file.readJSON("package.json"), - clean: { - dist: "build" - }, - compress: require("./grunt/grunt-compress"), - copy: { - main: { - files: [ - {expand: true, cwd: "src", src: ["public/**"], dest: "build"}, - {expand: true, cwd: "src", src: ["views/**"], dest: "build"}, - {expand: true, cwd: "landing-page-assets", src: ["**"], dest: "build/public/assets"}, - {expand: true, cwd: "src", src: ["shared/sample-data.json"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "src", src: ["shared/templates/**"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "src", src: ["shared/postgresql-error-codes.json"], dest: "build", filter: "isFile"}, - ] - }, - packages: { - files: [ - {expand: true, cwd: "", src: [".env"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "", src: [".gitignore"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "", src: ["release"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "", src: ["jest.config.js"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "", src: ["package.json"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "", src: ["package-lock.json"], dest: "build", filter: "isFile"}, - {expand: true, cwd: "", src: ["common_modules/**"], dest: "build"} - ] - } - }, - sync: { - main: { - files: [ - {cwd: "src", src: ["views/**", "public/**"], dest: "build/"}, // makes all src relative to cwd - ], - verbose: true, - failOnError: true, - compareUsing: "md5" - } - }, - uglify: { - all: { - files: [{ - expand: true, - cwd: "build", - src: "**/*.js", - dest: "build" - }] - }, - controllers: { - files: [{ - expand: true, - cwd: "build", - src: "controllers/*.js", - dest: "build" - }] - }, - routes: { - files: [{ - expand: true, - cwd: "build", - src: "routes/**/*.js", - dest: "build" - }] - }, - assets: { - files: [{ - expand: true, - cwd: "build", - src: "public/assets/**/*.js", - dest: "build" - }] - } - }, - shell: { - tsc: { - command: "tsc --build tsconfig.prod.json" - }, - esbuild: { - // command: "esbuild `find src -type f -name '*.ts'` --platform=node --minify=false --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=build" - command: "node esbuild && node cli/esbuild-patch" - }, - tsc_dev: { - command: "tsc --build tsconfig.json" - }, - swagger: { - command: "node ./cli/swagger" - }, - inline_queries: { - command: "node ./cli/inline-queries" - } - }, - watch: { - scripts: { - files: ["src/**/*.ts"], - tasks: ["shell:tsc_dev"], - options: { - debounceDelay: 250, - spawn: false, - } - }, - other: { - files: ["src/**/*.pug", "landing-page-assets/**"], - tasks: ["sync"] - } - } - }); - - grunt.registerTask("clean", ["clean"]); - grunt.registerTask("copy", ["copy:main"]); - grunt.registerTask("swagger", ["shell:swagger"]); - grunt.registerTask("build:tsc", ["shell:tsc"]); - grunt.registerTask("build", ["clean", "shell:tsc", "copy:main", "compress"]); - grunt.registerTask("build:es", ["clean", "shell:esbuild", "copy:main", "uglify:assets", "compress"]); - grunt.registerTask("build:strict", ["clean", "shell:tsc", "copy:packages", "uglify:all", "copy:main", "compress"]); - grunt.registerTask("dev", ["clean", "copy:main", "shell:tsc_dev", "shell:inline_queries", "watch"]); - - // Load the plugin that provides the "uglify" task. - grunt.loadNpmTasks("grunt-contrib-watch"); - grunt.loadNpmTasks("grunt-contrib-clean"); - grunt.loadNpmTasks("grunt-contrib-copy"); - grunt.loadNpmTasks("grunt-contrib-uglify"); - grunt.loadNpmTasks("grunt-contrib-compress"); - grunt.loadNpmTasks("grunt-shell"); - grunt.loadNpmTasks("grunt-sync"); - - // Default task(s). - grunt.registerTask("default", []); -}; diff --git a/worklenz-backend/package-lock.json b/worklenz-backend/package-lock.json index 2953defa..138d01ff 100644 --- a/worklenz-backend/package-lock.json +++ b/worklenz-backend/package-lock.json @@ -24,6 +24,7 @@ "cors": "^2.8.5", "cron": "^2.4.0", "crypto-js": "^4.1.1", + "csrf-sync": "^4.2.1", "csurf": "^1.11.0", "debug": "^4.3.4", "dotenv": "^16.3.1", @@ -99,26 +100,22 @@ "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "chokidar": "^3.5.3", + "concurrently": "^9.1.2", + "cpx2": "^8.0.0", "esbuild": "^0.17.19", "esbuild-envfile-plugin": "^1.0.5", "esbuild-node-externals": "^1.8.0", "eslint": "^8.45.0", "eslint-plugin-security": "^1.7.1", "fs-extra": "^10.1.0", - "grunt": "^1.6.1", - "grunt-contrib-clean": "^2.0.1", - "grunt-contrib-compress": "^2.0.0", - "grunt-contrib-copy": "^1.0.0", - "grunt-contrib-uglify": "^5.2.2", - "grunt-contrib-watch": "^1.1.0", - "grunt-shell": "^4.0.0", - "grunt-sync": "^0.8.2", "highcharts": "^11.1.0", "jest": "^28.1.3", "jest-sonar-reporter": "^2.0.0", "ncp": "^2.0.0", "nodeman": "^1.1.2", + "rimraf": "^6.0.1", "swagger-jsdoc": "^6.2.8", + "terser": "^5.40.0", "ts-jest": "^28.0.8", "ts-node": "^10.9.1", "tslint": "^6.1.3", @@ -3527,6 +3524,109 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3834,6 +3934,23 @@ "node": ">=8" } }, + "node_modules/@jest/core/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@jest/core/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4271,14 +4388,15 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" @@ -4294,14 +4412,26 @@ } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -4309,21 +4439,16 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.18", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", - "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true - }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", @@ -4360,6 +4485,22 @@ "node": ">=10" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -5940,10 +6081,11 @@ } }, "node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -5969,15 +6111,6 @@ "node": ">=0.4.0" } }, - "node_modules/adm-zip": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.10.tgz", - "integrity": "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==", - "dev": true, - "engines": { - "node": ">=6.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -6141,29 +6274,11 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, - "node_modules/array-each": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", - "integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, - "node_modules/array-slice": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", - "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -6539,18 +6654,6 @@ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" }, - "node_modules/body": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/body/-/body-5.1.0.tgz", - "integrity": "sha512-chUsBxGRtuElD6fmw1gHLpvnKdVLK302peeFa9ZqAEk8TyzZ3fygLyUEDDPTJvL9+Bor0dIwn6ePOsRM2y0zQQ==", - "dev": true, - "dependencies": { - "continuable-cache": "^0.3.1", - "error": "^7.0.0", - "raw-body": "~1.1.0", - "safe-json-parse": "~1.0.1" - } - }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -6599,31 +6702,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/body/node_modules/bytes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-1.0.0.tgz", - "integrity": "sha512-/x68VkHLeTl3/Ll8IvxdwzhrT+IyKc52e/oyHhA2RwqPqswSnjVbSddfPRwAsJtbilMAPSRWwAlpxdYsSWOTKQ==", - "dev": true - }, - "node_modules/body/node_modules/raw-body": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-1.1.7.tgz", - "integrity": "sha512-WmJJU2e9Y6M5UzTOkHaM7xJGAPQD8PNzx3bAd2+uhZAim6wDk6dAZxPVYLF67XhbR4hmKGh33Lpmh4XWrCH5Mg==", - "dev": true, - "dependencies": { - "bytes": "1", - "string_decoder": "0.10" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/body/node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "dev": true - }, "node_modules/bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -7174,6 +7252,124 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/concurrently": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.2.tgz", + "integrity": "sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/concurrently/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/connect-flash": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/connect-flash/-/connect-flash-0.1.1.tgz", @@ -7247,12 +7443,6 @@ "node": ">= 0.6" } }, - "node_modules/continuable-cache": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/continuable-cache/-/continuable-cache-0.3.1.tgz", - "integrity": "sha512-TF30kpKhTH8AGCG3dut0rdd/19B7Z+qCnrMoBLpyQu/2drZdNrrpcjPEoJeSVsQM+8KmWG5O56oPDjSSUsuTyA==", - "dev": true - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -7316,6 +7506,141 @@ "node": ">= 0.10" } }, + "node_modules/cpx2": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cpx2/-/cpx2-8.0.0.tgz", + "integrity": "sha512-RxD9jrSVNSOmfcbiPlr3XnKbUKH9K1w2HCv0skczUKhsZTueiDBecxuaSAKQkYSLQaGVA4ZQJZlTj5hVNNEvKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debounce": "^2.0.0", + "debug": "^4.1.1", + "duplexer": "^0.1.1", + "fs-extra": "^11.1.0", + "glob": "^11.0.0", + "glob2base": "0.0.12", + "ignore": "^6.0.2", + "minimatch": "^10.0.1", + "p-map": "^7.0.0", + "resolve": "^1.12.0", + "safe-buffer": "^5.2.0", + "shell-quote": "^1.8.0", + "subarg": "^1.0.0" + }, + "bin": { + "cpx": "bin/index.js" + }, + "engines": { + "node": "^20.0.0 || >=22.0.0", + "npm": ">=10" + } + }, + "node_modules/cpx2/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cpx2/node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/cpx2/node_modules/glob": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", + "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cpx2/node_modules/ignore": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-6.0.2.tgz", + "integrity": "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/cpx2/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cpx2/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/cpx2/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -7387,6 +7712,15 @@ "node": ">= 0.8" } }, + "node_modules/csrf-sync": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/csrf-sync/-/csrf-sync-4.2.1.tgz", + "integrity": "sha512-+q9tlUSCi/kbwr1NYwn5+MeuNhwxz3wSv1yl42BgIWfIuErZ3HajRwzvZTkfiyIqt1PZT8lQSlffhSYjCneN7g==", + "license": "ISC", + "dependencies": { + "http-errors": "^2.0.0" + } + }, "node_modules/csurf": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", @@ -7454,20 +7788,24 @@ "node": ">=0.6" } }, - "node_modules/dateformat": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", - "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/dayjs": { "version": "1.11.9", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz", "integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==" }, + "node_modules/debounce": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", + "integrity": "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -7557,15 +7895,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-file": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", - "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/detect-libc": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", @@ -7743,6 +8072,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -7842,15 +8178,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/error": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/error/-/error-7.2.1.tgz", - "integrity": "sha512-fo9HBvWnx3NGUKMvMwB/CBCMMrfEJgbDTVDEkPygA3Bdd3lM1OyCd+rbQ8BwnpF6GdVeOLDNmyL4N5Bg80ZvdA==", - "dev": true, - "dependencies": { - "string-template": "~0.2.1" - } - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -8318,12 +8645,6 @@ "node": ">= 0.6" } }, - "node_modules/eventemitter2": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz", - "integrity": "sha512-K7J4xq5xAD5jHsGM5ReWXRTFa3JRGofHiMcVgQ8PRwgWxzjHpMWCIzsmyf60+mh8KLsqYPcjUMa0AC4hd6lPyQ==", - "dev": true - }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -8404,18 +8725,6 @@ "node": ">=6" } }, - "node_modules/expand-tilde": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", - "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", - "dev": true, - "dependencies": { - "homedir-polyfill": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/expect": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/expect/-/expect-28.1.3.tgz", @@ -8599,12 +8908,6 @@ } ] }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true - }, "node_modules/fast-csv": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", @@ -8686,18 +8989,6 @@ "reusify": "^1.0.4" } }, - "node_modules/faye-websocket": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.10.0.tgz", - "integrity": "sha512-Xhj93RXbMSq8urNCUq4p9l0P6hnySJ/7YNRhYNug0bLOuii7pKO7xQFb5mx9xZXWCar88pLPb805PvUkwrLZpQ==", - "dev": true, - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -8712,21 +9003,6 @@ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -8739,12 +9015,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/file-sync-cmp": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/file-sync-cmp/-/file-sync-cmp-0.1.1.tgz", - "integrity": "sha512-0k45oWBokCqh2MOexeYKpyqmGKG+8mQ2Wd8iawx+uWd/weWJQAZ6SoPybagdCI4xFisag8iAR77WPm4h3pTfxA==", - "dev": true - }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -8796,6 +9066,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-index": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/find-index/-/find-index-0.1.1.tgz", + "integrity": "sha512-uJ5vWrfBKMcE6y2Z8834dwEZj9mNGxYa3t3I53OwFeuZ8D9oc2E5zcsrkuhX6h4iYrjhiv0T3szQmxlAV9uxDg==", + "dev": true, + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8812,46 +9089,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/findup-sync": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-5.0.0.tgz", - "integrity": "sha512-MzwXju70AuyflbgeOhzvQWAvvQdo1XL0A9bVvlXsYcFEBM87WR4OakL4OfZq+QRmr+duJubio+UtNQCPsVESzQ==", - "dev": true, - "dependencies": { - "detect-file": "^1.0.0", - "is-glob": "^4.0.3", - "micromatch": "^4.0.4", - "resolve-dir": "^1.0.1" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/fined": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz", - "integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==", - "dev": true, - "dependencies": { - "expand-tilde": "^2.0.2", - "is-plain-object": "^2.0.3", - "object.defaults": "^1.1.0", - "object.pick": "^1.2.0", - "parse-filepath": "^1.0.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/flagged-respawn": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz", - "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -8865,6 +9102,23 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flat-cache/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/flatted": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", @@ -8896,25 +9150,34 @@ } } }, - "node_modules/for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/for-own": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", - "integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==", + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, + "license": "ISC", "dependencies": { - "for-in": "^1.0.1" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/form-data": { @@ -9076,18 +9339,6 @@ "node": ">=10" } }, - "node_modules/gaze": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", - "integrity": "sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==", - "dev": true, - "dependencies": { - "globule": "^1.0.0" - }, - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/generic-pool": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", @@ -9159,15 +9410,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/getobject": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/getobject/-/getobject-1.0.2.tgz", - "integrity": "sha512-2zblDBaFcb3rB4rF77XVnuINOE2h2k/OnqXAiy0IrTxUfV1iFp3la33oAQVY9pCpWU268WFYVt2t71hlMuLsOg==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -9204,46 +9446,16 @@ "node": ">= 6" } }, - "node_modules/global-modules": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", - "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "node_modules/glob2base": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/glob2base/-/glob2base-0.0.12.tgz", + "integrity": "sha512-ZyqlgowMbfj2NPjxaZZ/EtsXlOch28FRXgMd64vqZWk1bT9+wvSRLYD1om9M7QfQru51zJPAT17qXm4/zd+9QA==", "dev": true, "dependencies": { - "global-prefix": "^1.0.1", - "is-windows": "^1.0.1", - "resolve-dir": "^1.0.0" + "find-index": "^0.1.1" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/global-prefix": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", - "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", - "dev": true, - "dependencies": { - "expand-tilde": "^2.0.2", - "homedir-polyfill": "^1.0.1", - "ini": "^1.3.4", - "is-windows": "^1.0.1", - "which": "^1.2.14" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/global-prefix/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" + "node": ">= 0.10" } }, "node_modules/globals": { @@ -9275,52 +9487,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globule": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.4.tgz", - "integrity": "sha512-OPTIfhMBh7JbBYDpa5b+Q5ptmMWKwcNcFSR/0c6t8V4f3ZAVBEsKNY37QdVqmLRYSMhOUGYrY0QhSoEpzGr/Eg==", - "dev": true, - "dependencies": { - "glob": "~7.1.1", - "lodash": "^4.17.21", - "minimatch": "~3.0.2" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/globule/node_modules/glob": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", - "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globule/node_modules/minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9344,709 +9510,6 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "node_modules/grunt": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/grunt/-/grunt-1.6.1.tgz", - "integrity": "sha512-/ABUy3gYWu5iBmrUSRBP97JLpQUm0GgVveDCp6t3yRNIoltIYw7rEj3g5y1o2PGPR2vfTRGa7WC/LZHLTXnEzA==", - "dev": true, - "dependencies": { - "dateformat": "~4.6.2", - "eventemitter2": "~0.4.13", - "exit": "~0.1.2", - "findup-sync": "~5.0.0", - "glob": "~7.1.6", - "grunt-cli": "~1.4.3", - "grunt-known-options": "~2.0.0", - "grunt-legacy-log": "~3.0.0", - "grunt-legacy-util": "~2.0.1", - "iconv-lite": "~0.6.3", - "js-yaml": "~3.14.0", - "minimatch": "~3.0.4", - "nopt": "~3.0.6" - }, - "bin": { - "grunt": "bin/grunt" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/grunt-cli": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.4.3.tgz", - "integrity": "sha512-9Dtx/AhVeB4LYzsViCjUQkd0Kw0McN2gYpdmGYKtE2a5Yt7v1Q+HYZVWhqXc/kGnxlMtqKDxSwotiGeFmkrCoQ==", - "dev": true, - "dependencies": { - "grunt-known-options": "~2.0.0", - "interpret": "~1.1.0", - "liftup": "~3.0.1", - "nopt": "~4.0.1", - "v8flags": "~3.2.0" - }, - "bin": { - "grunt": "bin/grunt" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/grunt-cli/node_modules/nopt": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz", - "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==", - "dev": true, - "dependencies": { - "abbrev": "1", - "osenv": "^0.1.4" - }, - "bin": { - "nopt": "bin/nopt.js" - } - }, - "node_modules/grunt-contrib-clean": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/grunt-contrib-clean/-/grunt-contrib-clean-2.0.1.tgz", - "integrity": "sha512-uRvnXfhiZt8akb/ZRDHJpQQtkkVkqc/opWO4Po/9ehC2hPxgptB9S6JHDC/Nxswo4CJSM0iFPT/Iym3cEMWzKA==", - "dev": true, - "dependencies": { - "async": "^3.2.3", - "rimraf": "^2.6.2" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "grunt": ">=0.4.5" - } - }, - "node_modules/grunt-contrib-clean/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/grunt-contrib-compress": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/grunt-contrib-compress/-/grunt-contrib-compress-2.0.0.tgz", - "integrity": "sha512-r/dAGx4qG+rmBFF4lb/hTktW2huGMGxkSLf9msh3PPtq0+cdQRQerZJ30UKevX3BLQsohwLzO0p1z/LrH6aKXQ==", - "dev": true, - "dependencies": { - "adm-zip": "^0.5.1", - "archiver": "^5.1.0", - "chalk": "^4.1.0", - "lodash": "^4.17.20", - "pretty-bytes": "^5.4.1", - "stream-buffers": "^3.0.2" - }, - "engines": { - "node": ">=10.16" - } - }, - "node_modules/grunt-contrib-compress/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/grunt-contrib-compress/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/grunt-contrib-compress/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/grunt-contrib-compress/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/grunt-contrib-compress/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-contrib-compress/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-contrib-copy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/grunt-contrib-copy/-/grunt-contrib-copy-1.0.0.tgz", - "integrity": "sha512-gFRFUB0ZbLcjKb67Magz1yOHGBkyU6uL29hiEW1tdQ9gQt72NuMKIy/kS6dsCbV0cZ0maNCb0s6y+uT1FKU7jA==", - "dev": true, - "dependencies": { - "chalk": "^1.1.1", - "file-sync-cmp": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-contrib-copy/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-contrib-copy/node_modules/ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-contrib-copy/node_modules/chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", - "dev": true, - "dependencies": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-contrib-copy/node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "dev": true, - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-contrib-copy/node_modules/supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/grunt-contrib-uglify": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/grunt-contrib-uglify/-/grunt-contrib-uglify-5.2.2.tgz", - "integrity": "sha512-ITxiWxrjjP+RZu/aJ5GLvdele+sxlznh+6fK9Qckio5ma8f7Iv8woZjRkGfafvpuygxNefOJNc+hfjjBayRn2Q==", - "dev": true, - "dependencies": { - "chalk": "^4.1.2", - "maxmin": "^3.0.0", - "uglify-js": "^3.16.1", - "uri-path": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/grunt-contrib-uglify/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/grunt-contrib-uglify/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/grunt-contrib-uglify/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/grunt-contrib-uglify/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/grunt-contrib-uglify/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-contrib-uglify/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-contrib-watch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/grunt-contrib-watch/-/grunt-contrib-watch-1.1.0.tgz", - "integrity": "sha512-yGweN+0DW5yM+oo58fRu/XIRrPcn3r4tQx+nL7eMRwjpvk+rQY6R8o94BPK0i2UhTg9FN21hS+m8vR8v9vXfeg==", - "dev": true, - "dependencies": { - "async": "^2.6.0", - "gaze": "^1.1.0", - "lodash": "^4.17.10", - "tiny-lr": "^1.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-contrib-watch/node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "dev": true, - "dependencies": { - "lodash": "^4.17.14" - } - }, - "node_modules/grunt-known-options": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/grunt-known-options/-/grunt-known-options-2.0.0.tgz", - "integrity": "sha512-GD7cTz0I4SAede1/+pAbmJRG44zFLPipVtdL9o3vqx9IEyb7b4/Y3s7r6ofI3CchR5GvYJ+8buCSioDv5dQLiA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt-legacy-log": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/grunt-legacy-log/-/grunt-legacy-log-3.0.0.tgz", - "integrity": "sha512-GHZQzZmhyq0u3hr7aHW4qUH0xDzwp2YXldLPZTCjlOeGscAOWWPftZG3XioW8MasGp+OBRIu39LFx14SLjXRcA==", - "dev": true, - "dependencies": { - "colors": "~1.1.2", - "grunt-legacy-log-utils": "~2.1.0", - "hooker": "~0.2.3", - "lodash": "~4.17.19" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/grunt-legacy-log-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/grunt-legacy-log-utils/-/grunt-legacy-log-utils-2.1.0.tgz", - "integrity": "sha512-lwquaPXJtKQk0rUM1IQAop5noEpwFqOXasVoedLeNzaibf/OPWjKYvvdqnEHNmU+0T0CaReAXIbGo747ZD+Aaw==", - "dev": true, - "dependencies": { - "chalk": "~4.1.0", - "lodash": "~4.17.19" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/grunt-legacy-log-utils/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/grunt-legacy-log-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/grunt-legacy-log-utils/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/grunt-legacy-log-utils/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/grunt-legacy-log-utils/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-legacy-log-utils/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-legacy-util": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/grunt-legacy-util/-/grunt-legacy-util-2.0.1.tgz", - "integrity": "sha512-2bQiD4fzXqX8rhNdXkAywCadeqiPiay0oQny77wA2F3WF4grPJXCvAcyoWUJV+po/b15glGkxuSiQCK299UC2w==", - "dev": true, - "dependencies": { - "async": "~3.2.0", - "exit": "~0.1.2", - "getobject": "~1.0.0", - "hooker": "~0.2.3", - "lodash": "~4.17.21", - "underscore.string": "~3.3.5", - "which": "~2.0.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/grunt-shell": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/grunt-shell/-/grunt-shell-4.0.0.tgz", - "integrity": "sha512-dHFy8VZDfWGYLTeNvIHze4PKXGvIlDWuN0UE7hUZstTQeiEyv1VmW1MaDYQ3X5tE3bCi3bEia1gGKH8z/f1czQ==", - "dev": true, - "dependencies": { - "chalk": "^3.0.0", - "npm-run-path": "^2.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - }, - "peerDependencies": { - "grunt": ">=1" - } - }, - "node_modules/grunt-shell/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/grunt-shell/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-shell/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/grunt-shell/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/grunt-shell/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-shell/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/grunt-sync": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/grunt-sync/-/grunt-sync-0.8.2.tgz", - "integrity": "sha512-PB+xKI9YPyZn3NZQXpKHfZVlxHdf1L8GMl+Wi0mLhYypWuOdZPW2EzTmSuhhFbXjkb0aIOxvII1zdZZEl9zqGg==", - "dev": true, - "dependencies": { - "fs-extra": "^6.0.1", - "glob": "^7.0.5", - "md5-file": "^2.0.3" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/grunt-sync/node_modules/fs-extra": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-6.0.1.tgz", - "integrity": "sha512-GnyIkKhhzXZUWFCaJzvyDLEEgDkPfb4/TPvJCJVuS8MWZgoSsErf++QpiAlDnKFcqhRlm+tIOcencCjyJE6ZCA==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - } - }, - "node_modules/grunt-sync/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/grunt-sync/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/grunt/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/grunt/node_modules/glob": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", - "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/grunt/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/grunt/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/grunt/node_modules/minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/grunt/node_modules/nopt": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", - "integrity": "sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==", - "dev": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - } - }, - "node_modules/grunt/node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true - }, - "node_modules/gzip-size": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.1.1.tgz", - "integrity": "sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA==", - "dev": true, - "dependencies": { - "duplexer": "^0.1.1", - "pify": "^4.0.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -10058,27 +9521,6 @@ "node": ">= 0.4.0" } }, - "node_modules/has-ansi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", - "dev": true, - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/has-ansi/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -10146,27 +9588,6 @@ "dev": true, "license": "https://www.highcharts.com/license" }, - "node_modules/homedir-polyfill": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", - "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", - "dev": true, - "dependencies": { - "parse-passwd": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/hooker": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/hooker/-/hooker-0.2.3.tgz", - "integrity": "sha512-t+UerCsQviSymAInD01Pw+Dn/usmz1sRO+3Zk1+lx8eg+WKpD2ulcwWqHHL0+aseRBr+3+vIhiG1K1JTwaIcTA==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/hpp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/hpp/-/hpp-0.2.3.tgz", @@ -10218,12 +9639,6 @@ "node": ">= 0.8" } }, - "node_modules/http-parser-js": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", - "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", - "dev": true - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -10375,12 +9790,6 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, - "node_modules/interpret": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz", - "integrity": "sha512-CLM8SNMDu7C5psFCn6Wg/tgpj/bKAg7hc2gWqcuR9OD5Ft9PhBpIu8PLicPeis+xDd6YX2ncI8MCA64I9tftIA==", - "dev": true - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -10389,19 +9798,6 @@ "node": ">= 0.10" } }, - "node_modules/is-absolute": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", - "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", - "dev": true, - "dependencies": { - "is-relative": "^1.0.0", - "is-windows": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -10508,18 +9904,6 @@ "node": ">=8" } }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-promise": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", @@ -10540,18 +9924,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-relative": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", - "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", - "dev": true, - "dependencies": { - "is-unc-path": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -10563,27 +9935,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-unc-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", - "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", - "dev": true, - "dependencies": { - "unc-path-regex": "^0.1.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -10595,15 +9946,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", @@ -10739,6 +10081,22 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/javascript-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz", @@ -12525,15 +11883,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -12622,40 +11971,6 @@ "immediate": "~3.0.5" } }, - "node_modules/liftup": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/liftup/-/liftup-3.0.1.tgz", - "integrity": "sha512-yRHaiQDizWSzoXk3APcA71eOI/UuhEkNN9DiW2Tt44mhYzX4joFoCZlxsSOF7RyeLlfqzFLQI1ngFq3ggMPhOw==", - "dev": true, - "dependencies": { - "extend": "^3.0.2", - "findup-sync": "^4.0.0", - "fined": "^1.2.0", - "flagged-respawn": "^1.0.1", - "is-plain-object": "^2.0.4", - "object.map": "^1.0.1", - "rechoir": "^0.7.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/liftup/node_modules/findup-sync": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz", - "integrity": "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==", - "dev": true, - "dependencies": { - "detect-file": "^1.0.0", - "is-glob": "^4.0.0", - "micromatch": "^4.0.2", - "resolve-dir": "^1.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -12667,12 +11982,6 @@ "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==" }, - "node_modules/livereload-js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-2.4.0.tgz", - "integrity": "sha512-XPQH8Z2GDP/Hwz2PCDrh2mth4yFejwA1OZ/81Ti3LgKyhDcEjsSsqFWZojHG0va/duGd+WyosY7eXLDoOyqcPw==", - "dev": true - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -12838,18 +12147,6 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, - "node_modules/make-iterator": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz", - "integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==", - "dev": true, - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -12859,15 +12156,6 @@ "tmpl": "1.0.5" } }, - "node_modules/map-cache": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", - "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -12877,100 +12165,6 @@ "node": ">= 0.4" } }, - "node_modules/maxmin": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/maxmin/-/maxmin-3.0.0.tgz", - "integrity": "sha512-wcahMInmGtg/7c6a75fr21Ch/Ks1Tb+Jtoan5Ft4bAI0ZvJqyOw8kkM7e7p8hDSzY805vmxwHT50KcjGwKyJ0g==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "figures": "^3.2.0", - "gzip-size": "^5.1.1", - "pretty-bytes": "^5.3.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/maxmin/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/maxmin/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/maxmin/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/maxmin/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/maxmin/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/maxmin/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/md5-file": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/md5-file/-/md5-file-2.0.7.tgz", - "integrity": "sha512-kWAICpJv8fIY0Ka/90iOX9nCJ407Fgj82ceWwcxi2HvVkKGHRMS/Y4caqBaju5skNYXiQohGUjwGZ7rVgzUhRw==", - "dev": true - }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -13383,27 +12577,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-run-path": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", - "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", - "dev": true, - "dependencies": { - "path-key": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -13440,46 +12613,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.defaults": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", - "integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==", - "dev": true, - "dependencies": { - "array-each": "^1.0.1", - "array-slice": "^1.0.0", - "for-own": "^1.0.0", - "isobject": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object.map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", - "integrity": "sha512-3+mAJu2PLfnSVGHwIWubpOFLscJANBKuB/6A4CxBstc4aqwQY0FWcsppuy4jU5GSB95yES5JHSI+33AWuS4k6w==", - "dev": true, - "dependencies": { - "for-own": "^1.0.0", - "make-iterator": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object.pick": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", - "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", - "dev": true, - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -13560,34 +12693,6 @@ "node": ">= 0.8.0" } }, - "node_modules/os-homedir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", - "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/osenv": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", - "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", - "dev": true, - "dependencies": { - "os-homedir": "^1.0.0", - "os-tmpdir": "^1.0.0" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -13618,6 +12723,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -13627,6 +12745,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -13644,20 +12769,6 @@ "node": ">=6" } }, - "node_modules/parse-filepath": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", - "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", - "dev": true, - "dependencies": { - "is-absolute": "^1.0.0", - "map-cache": "^0.2.0", - "path-root": "^0.1.1" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -13676,15 +12787,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-passwd": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", - "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", @@ -13813,25 +12915,41 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, - "node_modules/path-root": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", - "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", + "node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "path-root-regex": "^0.1.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=0.10.0" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-root-regex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", - "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", "dev": true, + "license": "ISC", "engines": { - "node": ">=0.10.0" + "node": "20 || >=22" + } + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" } }, "node_modules/path-to-regexp": { @@ -14081,15 +13199,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -14264,18 +13373,6 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-bytes": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/pretty-format": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", @@ -14650,18 +13747,6 @@ "node": ">=8.10.0" } }, - "node_modules/rechoir": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", - "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", - "dev": true, - "dependencies": { - "resolve": "^1.9.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/redis": { "version": "4.6.7", "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.7.tgz", @@ -14801,19 +13886,6 @@ "node": ">=8" } }, - "node_modules/resolve-dir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", - "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", - "dev": true, - "dependencies": { - "expand-tilde": "^2.0.0", - "global-modules": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -14843,19 +13915,85 @@ } }, "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", + "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", + "dev": true, + "license": "ISC", "dependencies": { - "glob": "^7.1.3" + "glob": "^11.0.0", + "package-json-from-dist": "^1.0.0" }, "bin": { - "rimraf": "bin.js" + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", + "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/rndm": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", @@ -14884,17 +14022,21 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, - "node_modules/safe-json-parse": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-1.0.1.tgz", - "integrity": "sha512-o0JmTu17WGUaUOHa1l0FPGXKBfijbxK6qoHzlkihsDXxzBHvJcA7zgviKR92Xs841rX9pK16unfphLq0/KqX7A==", - "dev": true - }, "node_modules/safe-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", @@ -15161,6 +14303,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", + "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -15392,12 +14547,6 @@ "node": ">= 10.x" } }, - "node_modules/sprintf-js": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", - "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", - "dev": true - }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -15435,15 +14584,6 @@ "node": ">= 0.8" } }, - "node_modules/stream-buffers": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.2.tgz", - "integrity": "sha512-DQi1h8VEBA/lURbSwFtEHnSTb9s2/pwLEaFuNhXwy1Dx3Sa0lOuYT2yNUr4/j2fs8oCAMANtrZ5OrPZtyVs3MQ==", - "dev": true, - "engines": { - "node": ">= 0.10.0" - } - }, "node_modules/streamx": { "version": "2.15.5", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.5.tgz", @@ -15493,12 +14633,6 @@ "node": ">=10" } }, - "node_modules/string-template": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz", - "integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==", - "dev": true - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -15512,6 +14646,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -15523,6 +14673,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -15558,6 +14722,16 @@ "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" }, + "node_modules/subarg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", + "integrity": "sha512-RIrIdRY0X1xojthNcVtgT9sjpOGagEUKpZdgBUi054OEPFo282yg+zE+t1Rj3+RqKq2xStL7uUHhY+AjbC4BXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.1.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -15736,6 +14910,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/terser": { + "version": "5.40.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.40.0.tgz", + "integrity": "sha512-cfeKl/jjwSR5ar7d0FGmave9hFGJT8obyo0z+CrQOylLDbk7X81nPU6vq9VORa5jU30SkDnT2FXjLbR8HLP+xA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.14.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/terser/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -15761,29 +14972,6 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, - "node_modules/tiny-lr": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tiny-lr/-/tiny-lr-1.1.1.tgz", - "integrity": "sha512-44yhA3tsaRoMOjQQ+5v5mVdqef+kH6Qze9jTpqtVufgYjYt08zyZAwNwwVBj3i1rJMnR52IxOW0LK0vBzgAkuA==", - "dev": true, - "dependencies": { - "body": "^5.1.0", - "debug": "^3.1.0", - "faye-websocket": "~0.10.0", - "livereload-js": "^2.3.0", - "object-assign": "^4.1.0", - "qs": "^6.4.0" - } - }, - "node_modules/tiny-lr/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, "node_modules/tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", @@ -15795,6 +14983,22 @@ "node": ">=8.17.0" } }, + "node_modules/tmp/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -15848,6 +15052,16 @@ "node": "*" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -16210,28 +15424,6 @@ "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" }, - "node_modules/unc-path-regex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", - "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/underscore.string": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.6.tgz", - "integrity": "sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==", - "dev": true, - "dependencies": { - "sprintf-js": "^1.1.1", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": "*" - } - }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -16373,15 +15565,6 @@ "punycode": "^2.1.0" } }, - "node_modules/uri-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/uri-path/-/uri-path-1.0.0.tgz", - "integrity": "sha512-8pMuAn4KacYdGMkFaoQARicp4HSw24/DHOVKWqVRJ8LhhAwPPFpdGvdL9184JVmUwe7vz7Z9n6IqI6t5n2ELdg==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", @@ -16436,18 +15619,6 @@ "node": ">=10.12.0" } }, - "node_modules/v8flags": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz", - "integrity": "sha512-mH8etigqMfiGWdeXpaaqGfs6BndypxusHHcv2qSHyZkGEznCd/qAXCWWRzeowtL54147cktFOC4P5y+kl8d8Jg==", - "dev": true, - "dependencies": { - "homedir-polyfill": "^1.0.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/validator": { "version": "13.9.0", "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", @@ -16487,29 +15658,6 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dev": true, - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -16608,6 +15756,61 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", diff --git a/worklenz-backend/package.json b/worklenz-backend/package.json index f3faaaec..cffa800b 100644 --- a/worklenz-backend/package.json +++ b/worklenz-backend/package.json @@ -11,16 +11,30 @@ "repository": "GITHUB_REPO_HERE", "author": "worklenz.com", "scripts": { - "start": "node ./build/bin/www", - "tcs": "grunt build:tsc", - "build": "grunt build", - "watch": "grunt watch", - "dev": "grunt dev", - "es": "esbuild `find src -type f -name '*.ts'` --platform=node --minify=true --watch=true --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=dist", - "copy": "grunt copy", + "test": "jest", + "start": "node build/bin/www.js", + "dev": "npm run build:dev && npm run watch", + "build": "npm run clean && npm run compile && npm run copy && npm run compress", + "build:dev": "npm run clean && npm run compile:dev && npm run copy", + "build:prod": "npm run clean && npm run compile:prod && npm run copy && npm run minify && npm run compress", + "clean": "rimraf build", + "compile": "tsc --build tsconfig.prod.json", + "compile:dev": "tsc --build tsconfig.json", + "compile:prod": "tsc --build tsconfig.prod.json", + "copy": "npm run copy:assets && npm run copy:views && npm run copy:config && npm run copy:shared", + "copy:assets": "npx cpx2 \"src/public/**\" build/public", + "copy:views": "npx cpx2 \"src/views/**\" build/views", + "copy:config": "npx cpx2 \".env\" build && npx cpx2 \"package.json\" build", + "copy:shared": "npx cpx2 \"src/shared/postgresql-error-codes.json\" build/shared && npx cpx2 \"src/shared/sample-data.json\" build/shared && npx cpx2 \"src/shared/templates/**\" build/shared/templates", + "watch": "concurrently \"npm run watch:ts\" \"npm run watch:assets\"", + "watch:ts": "tsc --build tsconfig.json --watch", + "watch:assets": "npx cpx2 \"src/{public,views}/**\" build --watch", + "minify": "terser build/**/*.js --compress --mangle --output-dir build", + "compress": "node scripts/compress.js", + "swagger": "node ./cli/swagger", + "inline-queries": "node ./cli/inline-queries", "sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties", "tsc": "tsc", - "test": "jest --setupFiles dotenv/config", "test:watch": "jest --watch --setupFiles dotenv/config" }, "jestSonar": { @@ -45,6 +59,7 @@ "cors": "^2.8.5", "cron": "^2.4.0", "crypto-js": "^4.1.1", + "csrf-sync": "^4.2.1", "csurf": "^1.11.0", "debug": "^4.3.4", "dotenv": "^16.3.1", @@ -120,26 +135,22 @@ "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "chokidar": "^3.5.3", + "concurrently": "^9.1.2", + "cpx2": "^8.0.0", "esbuild": "^0.17.19", "esbuild-envfile-plugin": "^1.0.5", "esbuild-node-externals": "^1.8.0", "eslint": "^8.45.0", "eslint-plugin-security": "^1.7.1", "fs-extra": "^10.1.0", - "grunt": "^1.6.1", - "grunt-contrib-clean": "^2.0.1", - "grunt-contrib-compress": "^2.0.0", - "grunt-contrib-copy": "^1.0.0", - "grunt-contrib-uglify": "^5.2.2", - "grunt-contrib-watch": "^1.1.0", - "grunt-shell": "^4.0.0", - "grunt-sync": "^0.8.2", "highcharts": "^11.1.0", "jest": "^28.1.3", "jest-sonar-reporter": "^2.0.0", "ncp": "^2.0.0", "nodeman": "^1.1.2", + "rimraf": "^6.0.1", "swagger-jsdoc": "^6.2.8", + "terser": "^5.40.0", "ts-jest": "^28.0.8", "ts-node": "^10.9.1", "tslint": "^6.1.3", diff --git a/worklenz-backend/scripts/compress.js b/worklenz-backend/scripts/compress.js new file mode 100644 index 00000000..6a946163 --- /dev/null +++ b/worklenz-backend/scripts/compress.js @@ -0,0 +1,53 @@ +const fs = require('fs'); +const path = require('path'); +const { createGzip } = require('zlib'); +const { pipeline } = require('stream'); + +async function compressFile(inputPath, outputPath) { + return new Promise((resolve, reject) => { + const gzip = createGzip(); + const source = fs.createReadStream(inputPath); + const destination = fs.createWriteStream(outputPath); + + pipeline(source, gzip, destination, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} + +async function compressDirectory(dir) { + const files = fs.readdirSync(dir, { withFileTypes: true }); + + for (const file of files) { + const fullPath = path.join(dir, file.name); + + if (file.isDirectory()) { + await compressDirectory(fullPath); + } else if (file.name.endsWith('.js') || file.name.endsWith('.css')) { + const gzPath = fullPath + '.gz'; + await compressFile(fullPath, gzPath); + console.log(`Compressed: ${fullPath} -> ${gzPath}`); + } + } +} + +async function main() { + try { + const buildDir = path.join(__dirname, '../build'); + if (fs.existsSync(buildDir)) { + await compressDirectory(buildDir); + console.log('Compression complete!'); + } else { + console.log('Build directory not found. Run build first.'); + } + } catch (error) { + console.error('Compression failed:', error); + process.exit(1); + } +} + +main(); \ No newline at end of file diff --git a/worklenz-backend/src/app.ts b/worklenz-backend/src/app.ts index 4da00a0e..68f18af3 100644 --- a/worklenz-backend/src/app.ts +++ b/worklenz-backend/src/app.ts @@ -6,7 +6,7 @@ import logger from "morgan"; import helmet from "helmet"; import compression from "compression"; import passport from "passport"; -import csurf from "csurf"; +import { csrfSync } from "csrf-sync"; import rateLimit from "express-rate-limit"; import cors from "cors"; import flash from "connect-flash"; @@ -112,17 +112,13 @@ function isLoggedIn(req: Request, _res: Response, next: NextFunction) { return req.user ? next() : next(createError(401)); } -// CSRF configuration -const csrfProtection = csurf({ - cookie: { - key: "XSRF-TOKEN", - path: "/", - httpOnly: false, - secure: isProduction(), // Only secure in production - sameSite: isProduction() ? "none" : "lax", // Different settings for dev vs prod - domain: isProduction() ? ".worklenz.com" : undefined // Only set domain in production - }, - ignoreMethods: ["HEAD", "OPTIONS"] +// CSRF configuration using csrf-sync for session-based authentication +const { + invalidCsrfTokenError, + generateToken, + csrfSynchronisedProtection, +} = csrfSync({ + getTokenFromRequest: (req: Request) => req.headers["x-csrf-token"] as string || (req.body && req.body["_csrf"]) }); // Apply CSRF selectively (exclude webhooks and public routes) @@ -135,38 +131,25 @@ app.use((req, res, next) => { ) { next(); } else { - csrfProtection(req, res, next); + csrfSynchronisedProtection(req, res, next); } }); -// Set CSRF token cookie +// Set CSRF token method on request object for compatibility app.use((req: Request, res: Response, next: NextFunction) => { - if (req.csrfToken) { - const token = req.csrfToken(); - res.cookie("XSRF-TOKEN", token, { - httpOnly: false, - secure: isProduction(), - sameSite: isProduction() ? "none" : "lax", - domain: isProduction() ? ".worklenz.com" : undefined, - path: "/" - }); + // Add csrfToken method to request object for compatibility + if (!req.csrfToken && generateToken) { + req.csrfToken = (overwrite?: boolean) => generateToken(req, overwrite); } next(); }); // CSRF token refresh endpoint app.get("/csrf-token", (req: Request, res: Response) => { - if (req.csrfToken) { - const token = req.csrfToken(); - res.cookie("XSRF-TOKEN", token, { - httpOnly: false, - secure: isProduction(), - sameSite: isProduction() ? "none" : "lax", - domain: isProduction() ? ".worklenz.com" : undefined, - path: "/" - }); - res.status(200).json({ done: true, message: "CSRF token refreshed" }); - } else { + try { + const token = generateToken(req); + res.status(200).json({ done: true, message: "CSRF token refreshed", token }); + } catch (error) { res.status(500).json({ done: false, message: "Failed to generate CSRF token" }); } }); @@ -219,7 +202,7 @@ if (isInternalServer()) { // CSRF error handler app.use((err: any, req: Request, res: Response, next: NextFunction) => { - if (err.code === "EBADCSRFTOKEN") { + if (err === invalidCsrfTokenError) { return res.status(403).json({ done: false, message: "Invalid CSRF token", diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 8b313508..3181a25e 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -13,6 +13,7 @@ import router from './app/routes'; // Hooks & Utils import { useAppSelector } from './hooks/useAppSelector'; import { initMixpanel } from './utils/mixpanelInit'; +import { initializeCsrfToken } from './api/api-client'; // Types & Constants import { Language } from './features/i18n/localesSlice'; @@ -35,6 +36,13 @@ const App: React.FC<{ children: React.ReactNode }> = ({ children }) => { }); }, [language]); + // Initialize CSRF token on app startup + useEffect(() => { + initializeCsrfToken().catch(error => { + logger.error('Failed to initialize CSRF token:', error); + }); + }, []); + return ( }> diff --git a/worklenz-frontend/src/api/api-client.ts b/worklenz-frontend/src/api/api-client.ts index ec43f7a5..721a5274 100644 --- a/worklenz-frontend/src/api/api-client.ts +++ b/worklenz-frontend/src/api/api-client.ts @@ -4,27 +4,36 @@ import alertService from '@/services/alerts/alertService'; import logger from '@/utils/errorLogger'; import config from '@/config/env'; -export const getCsrfToken = (): string | null => { - const match = document.cookie.split('; ').find(cookie => cookie.startsWith('XSRF-TOKEN=')); +// Store CSRF token in memory (since csrf-sync uses session-based tokens) +let csrfToken: string | null = null; - if (!match) { - return null; - } - return decodeURIComponent(match.split('=')[1]); +export const getCsrfToken = (): string | null => { + return csrfToken; }; -// Function to refresh CSRF token if needed +// Function to refresh CSRF token from server export const refreshCsrfToken = async (): Promise => { try { // Make a GET request to the server to get a fresh CSRF token - await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true }); - return getCsrfToken(); + const response = await axios.get(`${config.apiUrl}/csrf-token`, { withCredentials: true }); + if (response.data && response.data.token) { + csrfToken = response.data.token; + return csrfToken; + } + return null; } catch (error) { console.error('Failed to refresh CSRF token:', error); return null; } }; +// Initialize CSRF token on app load +export const initializeCsrfToken = async (): Promise => { + if (!csrfToken) { + await refreshCsrfToken(); + } +}; + const apiClient = axios.create({ baseURL: config.apiUrl, withCredentials: true, @@ -36,12 +45,16 @@ const apiClient = axios.create({ // Request interceptor apiClient.interceptors.request.use( - config => { - const token = getCsrfToken(); - if (token) { - config.headers['X-CSRF-Token'] = token; + async config => { + // Ensure we have a CSRF token before making requests + if (!csrfToken) { + await refreshCsrfToken(); + } + + if (csrfToken) { + config.headers['X-CSRF-Token'] = csrfToken; } else { - console.warn('No CSRF token found'); + console.warn('No CSRF token available'); } return config; }, @@ -84,7 +97,7 @@ apiClient.interceptors.response.use( (typeof errorResponse.data === 'object' && errorResponse.data !== null && 'message' in errorResponse.data && - errorResponse.data.message === 'Invalid CSRF token' || + (errorResponse.data.message === 'invalid csrf token' || errorResponse.data.message === 'Invalid CSRF token') || (error as any).code === 'EBADCSRFTOKEN')) { alertService.error('Security Error', 'Invalid security token. Refreshing your session...'); @@ -94,7 +107,7 @@ apiClient.interceptors.response.use( // Update the token in the failed request error.config.headers['X-CSRF-Token'] = newToken; // Retry the original request with the new token - return axios(error.config); + return apiClient(error.config); } else { // If token refresh failed, redirect to login window.location.href = '/auth/login'; diff --git a/worklenz-frontend/src/api/home-page/home-page.api.service.ts b/worklenz-frontend/src/api/home-page/home-page.api.service.ts index 74f5615a..b71e03a3 100644 --- a/worklenz-frontend/src/api/home-page/home-page.api.service.ts +++ b/worklenz-frontend/src/api/home-page/home-page.api.service.ts @@ -5,7 +5,7 @@ import { toQueryString } from '@/utils/toQueryString'; import { IHomeTasksModel, IHomeTasksConfig } from '@/types/home/home-page.types'; import { IMyTask } from '@/types/home/my-tasks.types'; import { IProject } from '@/types/project/project.types'; -import { getCsrfToken } from '../api-client'; +import { getCsrfToken, refreshCsrfToken } from '../api-client'; import config from '@/config/env'; const rootUrl = '/home'; @@ -14,9 +14,18 @@ const api = createApi({ reducerPath: 'homePageApi', baseQuery: fetchBaseQuery({ baseUrl: `${config.apiUrl}${API_BASE_URL}`, - prepareHeaders: headers => { - headers.set('X-CSRF-Token', getCsrfToken() || ''); + prepareHeaders: async headers => { + // Get CSRF token, refresh if needed + let token = getCsrfToken(); + if (!token) { + token = await refreshCsrfToken(); + } + + if (token) { + headers.set('X-CSRF-Token', token); + } headers.set('Content-Type', 'application/json'); + return headers; }, credentials: 'include', }), diff --git a/worklenz-frontend/src/api/projects/projects.v1.api.service.ts b/worklenz-frontend/src/api/projects/projects.v1.api.service.ts index 1fe279d5..1ad45b8b 100644 --- a/worklenz-frontend/src/api/projects/projects.v1.api.service.ts +++ b/worklenz-frontend/src/api/projects/projects.v1.api.service.ts @@ -5,7 +5,7 @@ import { IProjectCategory } from '@/types/project/projectCategory.types'; import { IProjectsViewModel } from '@/types/project/projectsViewModel.types'; import { IServerResponse } from '@/types/common.types'; import { IProjectMembersViewModel } from '@/types/projectMember.types'; -import { getCsrfToken } from '../api-client'; +import { getCsrfToken, refreshCsrfToken } from '../api-client'; import config from '@/config/env'; const rootUrl = '/projects'; @@ -14,9 +14,18 @@ export const projectsApi = createApi({ reducerPath: 'projectsApi', baseQuery: fetchBaseQuery({ baseUrl: `${config.apiUrl}${API_BASE_URL}`, - prepareHeaders: headers => { - headers.set('X-CSRF-Token', getCsrfToken() || ''); + prepareHeaders: async headers => { + // Get CSRF token, refresh if needed + let token = getCsrfToken(); + if (!token) { + token = await refreshCsrfToken(); + } + + if (token) { + headers.set('X-CSRF-Token', token); + } headers.set('Content-Type', 'application/json'); + return headers; }, credentials: 'include', }), From 6ffdbc64d07cd0ca56662f89542a64986a6351ff Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 30 May 2025 11:10:11 +0530 Subject: [PATCH 26/35] refactor(navbar): comment out license expiry alert for future implementation - Commented out the conditional rendering of the license expiry alert in the Navbar component for future adjustments. --- worklenz-frontend/src/features/navbar/navbar.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/worklenz-frontend/src/features/navbar/navbar.tsx b/worklenz-frontend/src/features/navbar/navbar.tsx index 295a8a17..41d7c0e7 100644 --- a/worklenz-frontend/src/features/navbar/navbar.tsx +++ b/worklenz-frontend/src/features/navbar/navbar.tsx @@ -101,14 +101,6 @@ const Navbar = () => { justifyContent: 'space-between', }} > - {daysUntilExpiry !== null && ((daysUntilExpiry <= 3 && daysUntilExpiry > 0) || (daysUntilExpiry >= -7 && daysUntilExpiry < 0)) && ( - 0 ? `Your license will expire in ${daysUntilExpiry} days` : `Your license has expired ${Math.abs(daysUntilExpiry)} days ago`} - type="warning" - showIcon - style={{ width: '100%', marginTop: 12 }} - /> - )} Date: Fri, 30 May 2025 11:40:27 +0530 Subject: [PATCH 27/35] feat(task-list): implement optimized task group handling and filter data loading - Introduced `useFilterDataLoader` hook to manage asynchronous loading of filter data without blocking the main UI. - Created `TaskGroupWrapperOptimized` for improved rendering of task groups with drag-and-drop functionality. - Refactored `ProjectViewTaskList` to utilize the new optimized components and enhance loading state management. - Added `TaskGroup` component for better organization and interaction with task groups. - Updated `TaskListFilters` to leverage the new filter data loading mechanism, ensuring a smoother user experience. --- .../src/hooks/useFilterDataLoader.ts | 69 ++++ .../src/hooks/useTaskDragAndDrop.ts | 146 ++++++++ .../src/hooks/useTaskSocketHandlers.ts | 343 ++++++++++++++++++ .../components/task-group/task-group.tsx | 241 ++++++++++++ .../taskList/project-view-task-list.tsx | 82 +++-- .../taskList/task-group-wrapper-optimized.tsx | 112 ++++++ .../task-list-filters/task-list-filters.tsx | 43 ++- 7 files changed, 1000 insertions(+), 36 deletions(-) create mode 100644 worklenz-frontend/src/hooks/useFilterDataLoader.ts create mode 100644 worklenz-frontend/src/hooks/useTaskDragAndDrop.ts create mode 100644 worklenz-frontend/src/hooks/useTaskSocketHandlers.ts create mode 100644 worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx diff --git a/worklenz-frontend/src/hooks/useFilterDataLoader.ts b/worklenz-frontend/src/hooks/useFilterDataLoader.ts new file mode 100644 index 00000000..e3aa4f41 --- /dev/null +++ b/worklenz-frontend/src/hooks/useFilterDataLoader.ts @@ -0,0 +1,69 @@ +import { useEffect, useCallback } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; +import { + fetchLabelsByProject, + fetchTaskAssignees, +} from '@/features/tasks/tasks.slice'; +import { getTeamMembers } from '@/features/team-members/team-members.slice'; + +/** + * Hook to manage filter data loading independently of main task list loading + * This ensures filter data loading doesn't block the main UI skeleton + */ +export const useFilterDataLoader = () => { + const dispatch = useAppDispatch(); + + const { priorities } = useAppSelector(state => ({ + priorities: state.priorityReducer.priorities, + })); + + const { projectId } = useAppSelector(state => ({ + projectId: state.projectReducer.projectId, + })); + + // Load filter data asynchronously + const loadFilterData = useCallback(async () => { + try { + // Load priorities if not already loaded (usually fast/cached) + if (!priorities.length) { + dispatch(fetchPriorities()); + } + + // Load project-specific data in parallel without blocking + if (projectId) { + // These dispatch calls are fire-and-forget + // They will update the UI when ready, but won't block initial render + dispatch(fetchLabelsByProject(projectId)); + dispatch(fetchTaskAssignees(projectId)); + } + + // Load team members for member filters + dispatch(getTeamMembers({ + index: 0, + size: 100, + field: null, + order: null, + search: null, + all: true + })); + } catch (error) { + console.error('Error loading filter data:', error); + // Don't throw - filter loading errors shouldn't break the main UI + } + }, [dispatch, priorities.length, projectId]); + + // Load filter data on mount and when dependencies change + useEffect(() => { + // Use setTimeout to ensure this runs after the main component render + // This prevents filter loading from blocking the initial render + const timeoutId = setTimeout(loadFilterData, 0); + + return () => clearTimeout(timeoutId); + }, [loadFilterData]); + + return { + loadFilterData, + }; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts b/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts new file mode 100644 index 00000000..cab0a361 --- /dev/null +++ b/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts @@ -0,0 +1,146 @@ +import { useMemo, useCallback } from 'react'; +import { + DndContext, + DragEndEvent, + DragOverEvent, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, + KeyboardSensor, + TouchSensor, +} from '@dnd-kit/core'; +import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { updateTaskStatus } from '@/features/tasks/tasks.slice'; +import { ITaskListGroup } from '@/types/tasks/taskList.types'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; + +export const useTaskDragAndDrop = () => { + const dispatch = useAppDispatch(); + const { taskGroups, groupBy } = useAppSelector(state => ({ + taskGroups: state.taskReducer.taskGroups, + groupBy: state.taskReducer.groupBy, + })); + + // Memoize sensors configuration for better performance + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }) + ); + + const handleDragStart = useCallback((event: DragStartEvent) => { + // Add visual feedback for drag start + const { active } = event; + if (active) { + document.body.style.cursor = 'grabbing'; + } + }, []); + + const handleDragOver = useCallback((event: DragOverEvent) => { + // Handle drag over logic if needed + // This can be used for visual feedback during drag + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + // Reset cursor + document.body.style.cursor = ''; + + const { active, over } = event; + + if (!active || !over || !taskGroups) { + return; + } + + try { + const activeId = active.id as string; + const overId = over.id as string; + + // Find the task being dragged + let draggedTask: IProjectTask | null = null; + let sourceGroupId: string | null = null; + + for (const group of taskGroups) { + const task = group.tasks?.find((t: IProjectTask) => t.id === activeId); + if (task) { + draggedTask = task; + sourceGroupId = group.id; + break; + } + } + + if (!draggedTask || !sourceGroupId) { + console.warn('Could not find dragged task'); + return; + } + + // Determine target group + let targetGroupId: string | null = null; + + // Check if dropped on a group container + const targetGroup = taskGroups.find((group: ITaskListGroup) => group.id === overId); + if (targetGroup) { + targetGroupId = targetGroup.id; + } else { + // Check if dropped on another task + for (const group of taskGroups) { + const targetTask = group.tasks?.find((t: IProjectTask) => t.id === overId); + if (targetTask) { + targetGroupId = group.id; + break; + } + } + } + + if (!targetGroupId || targetGroupId === sourceGroupId) { + return; // No change needed + } + + // Update task status based on group change + const targetGroupData = taskGroups.find((group: ITaskListGroup) => group.id === targetGroupId); + if (targetGroupData && groupBy === 'status') { + const updatePayload: any = { + task_id: draggedTask.id, + status_id: targetGroupData.id, + }; + + if (draggedTask.parent_task_id) { + updatePayload.parent_task = draggedTask.parent_task_id; + } + + dispatch(updateTaskStatus(updatePayload)); + } + } catch (error) { + console.error('Error handling drag end:', error); + } + }, + [taskGroups, groupBy, dispatch] + ); + + // Memoize the drag and drop configuration + const dragAndDropConfig = useMemo( + () => ({ + sensors, + onDragStart: handleDragStart, + onDragOver: handleDragOver, + onDragEnd: handleDragEnd, + }), + [sensors, handleDragStart, handleDragOver, handleDragEnd] + ); + + return dragAndDropConfig; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts new file mode 100644 index 00000000..7c85ead6 --- /dev/null +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -0,0 +1,343 @@ +import { useCallback, useEffect } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useSocket } from '@/socket/socketContext'; +import { useAuthService } from '@/hooks/useAuth'; +import { SocketEvents } from '@/shared/socket-events'; +import logger from '@/utils/errorLogger'; +import alertService from '@/services/alerts/alertService'; + +import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response'; +import { ILabelsChangeResponse } from '@/types/tasks/taskList.types'; +import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types'; +import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types'; +import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-response'; +import { InlineMember } from '@/types/teamMembers/inlineMember.types'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { ITaskListGroup } from '@/types/tasks/taskList.types'; + +import { + fetchTaskAssignees, + updateTaskAssignees, + fetchLabelsByProject, + updateTaskLabel, + updateTaskStatus, + updateTaskPriority, + updateTaskEndDate, + updateTaskEstimation, + updateTaskName, + updateTaskPhase, + updateTaskStartDate, + updateTaskDescription, + updateSubTasks, + updateTaskProgress, +} from '@/features/tasks/tasks.slice'; +import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; +import { + setStartDate, + setTaskAssignee, + setTaskEndDate, + setTaskLabels, + setTaskPriority, + setTaskStatus, + setTaskSubscribers, +} from '@/features/task-drawer/task-drawer.slice'; +import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; + +export const useTaskSocketHandlers = () => { + const dispatch = useAppDispatch(); + const { socket } = useSocket(); + const currentSession = useAuthService().getCurrentSession(); + + const { loadingAssignees, taskGroups } = useAppSelector((state: any) => state.taskReducer); + const { projectId } = useAppSelector((state: any) => state.projectReducer); + + // Memoize socket event handlers + const handleAssigneesUpdate = useCallback( + (data: ITaskAssigneesUpdateResponse) => { + if (!data) return; + + const updatedAssignees = data.assignees?.map(assignee => ({ + ...assignee, + selected: true, + })) || []; + + const groupId = taskGroups?.find((group: ITaskListGroup) => + group.tasks?.some( + (task: IProjectTask) => + task.id === data.id || + (task.sub_tasks && task.sub_tasks.some((subtask: IProjectTask) => subtask.id === data.id)) + ) + )?.id; + + if (groupId) { + dispatch( + updateTaskAssignees({ + groupId, + taskId: data.id, + assignees: updatedAssignees, + }) + ); + + dispatch( + setTaskAssignee({ + ...data, + manual_progress: false, + } as IProjectTask) + ); + + if (currentSession?.team_id && !loadingAssignees) { + dispatch(fetchTaskAssignees(currentSession.team_id)); + } + } + }, + [taskGroups, dispatch, currentSession?.team_id, loadingAssignees] + ); + + const handleLabelsChange = useCallback( + async (labels: ILabelsChangeResponse) => { + if (!labels) return; + + await Promise.all([ + dispatch(updateTaskLabel(labels)), + dispatch(setTaskLabels(labels)), + dispatch(fetchLabels()), + projectId && dispatch(fetchLabelsByProject(projectId)), + ]); + }, + [dispatch, projectId] + ); + + const handleTaskStatusChange = useCallback( + (response: ITaskListStatusChangeResponse) => { + if (!response) return; + + if (response.completed_deps === false) { + alertService.error( + 'Task is not completed', + 'Please complete the task dependencies before proceeding' + ); + return; + } + + dispatch(updateTaskStatus(response)); + dispatch(deselectAll()); + }, + [dispatch] + ); + + const handleTaskProgress = useCallback( + (data: { + id: string; + status: string; + complete_ratio: number; + completed_count: number; + total_tasks_count: number; + parent_task: string; + }) => { + if (!data) return; + + dispatch( + updateTaskProgress({ + taskId: data.parent_task || data.id, + progress: data.complete_ratio, + totalTasksCount: data.total_tasks_count, + completedCount: data.completed_count, + }) + ); + }, + [dispatch] + ); + + const handlePriorityChange = useCallback( + (response: ITaskListPriorityChangeResponse) => { + if (!response) return; + + dispatch(updateTaskPriority(response)); + dispatch(setTaskPriority(response)); + dispatch(deselectAll()); + }, + [dispatch] + ); + + const handleEndDateChange = useCallback( + (task: { + id: string; + parent_task: string | null; + end_date: string; + }) => { + if (!task) return; + + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; + + dispatch(updateTaskEndDate({ task: taskWithProgress })); + dispatch(setTaskEndDate(taskWithProgress)); + }, + [dispatch] + ); + + const handleTaskNameChange = useCallback( + (data: { id: string; parent_task: string; name: string }) => { + if (!data) return; + dispatch(updateTaskName(data)); + }, + [dispatch] + ); + + const handlePhaseChange = useCallback( + (data: ITaskPhaseChangeResponse) => { + if (!data) return; + dispatch(updateTaskPhase(data)); + dispatch(deselectAll()); + }, + [dispatch] + ); + + const handleStartDateChange = useCallback( + (task: { + id: string; + parent_task: string | null; + start_date: string; + }) => { + if (!task) return; + + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; + + dispatch(updateTaskStartDate({ task: taskWithProgress })); + dispatch(setStartDate(taskWithProgress)); + }, + [dispatch] + ); + + const handleTaskSubscribersChange = useCallback( + (data: InlineMember[]) => { + if (!data) return; + dispatch(setTaskSubscribers(data)); + }, + [dispatch] + ); + + const handleEstimationChange = useCallback( + (task: { + id: string; + parent_task: string | null; + estimation: number; + }) => { + if (!task) return; + + const taskWithProgress = { + ...task, + manual_progress: false, + } as IProjectTask; + + dispatch(updateTaskEstimation({ task: taskWithProgress })); + }, + [dispatch] + ); + + const handleTaskDescriptionChange = useCallback( + (data: { + id: string; + parent_task: string; + description: string; + }) => { + if (!data) return; + dispatch(updateTaskDescription(data)); + }, + [dispatch] + ); + + const handleNewTaskReceived = useCallback( + (data: IProjectTask) => { + if (!data) return; + if (data.parent_task_id) { + dispatch(updateSubTasks(data)); + } + }, + [dispatch] + ); + + const handleTaskProgressUpdated = useCallback( + (data: { + task_id: string; + progress_value?: number; + weight?: number; + }) => { + if (!data || !taskGroups) return; + + if (data.progress_value !== undefined) { + for (const group of taskGroups) { + const task = group.tasks?.find((task: IProjectTask) => task.id === data.task_id); + if (task) { + dispatch( + updateTaskProgress({ + taskId: data.task_id, + progress: data.progress_value, + totalTasksCount: task.total_tasks_count || 0, + completedCount: task.completed_count || 0, + }) + ); + break; + } + } + } + }, + [dispatch, taskGroups] + ); + + // Register socket event listeners + useEffect(() => { + if (!socket) return; + + const eventHandlers = [ + { event: SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handler: handleAssigneesUpdate }, + { event: SocketEvents.TASK_LABELS_CHANGE.toString(), handler: handleLabelsChange }, + { event: SocketEvents.TASK_STATUS_CHANGE.toString(), handler: handleTaskStatusChange }, + { event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgress }, + { event: SocketEvents.TASK_PRIORITY_CHANGE.toString(), handler: handlePriorityChange }, + { event: SocketEvents.TASK_END_DATE_CHANGE.toString(), handler: handleEndDateChange }, + { event: SocketEvents.TASK_NAME_CHANGE.toString(), handler: handleTaskNameChange }, + { event: SocketEvents.TASK_PHASE_CHANGE.toString(), handler: handlePhaseChange }, + { event: SocketEvents.TASK_START_DATE_CHANGE.toString(), handler: handleStartDateChange }, + { event: SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handler: handleTaskSubscribersChange }, + { event: SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handler: handleEstimationChange }, + { event: SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handler: handleTaskDescriptionChange }, + { event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived }, + { event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated }, + ]; + + // Register all event listeners + eventHandlers.forEach(({ event, handler }) => { + socket.on(event, handler); + }); + + // Cleanup function + return () => { + eventHandlers.forEach(({ event, handler }) => { + socket.off(event, handler); + }); + }; + }, [ + socket, + handleAssigneesUpdate, + handleLabelsChange, + handleTaskStatusChange, + handleTaskProgress, + handlePriorityChange, + handleEndDateChange, + handleTaskNameChange, + handlePhaseChange, + handleStartDateChange, + handleTaskSubscribersChange, + handleEstimationChange, + handleTaskDescriptionChange, + handleNewTaskReceived, + handleTaskProgressUpdated, + ]); +}; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx new file mode 100644 index 00000000..e5800fe4 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/components/task-group/task-group.tsx @@ -0,0 +1,241 @@ +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDroppable } from '@dnd-kit/core'; +import Flex from 'antd/es/flex'; +import Badge from 'antd/es/badge'; +import Button from 'antd/es/button'; +import Dropdown from 'antd/es/dropdown'; +import Input from 'antd/es/input'; +import Typography from 'antd/es/typography'; +import { MenuProps } from 'antd/es/menu'; +import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons'; + +import { colors } from '@/styles/colors'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { ITaskListGroup } from '@/types/tasks/taskList.types'; +import Collapsible from '@/components/collapsible/collapsible'; +import TaskListTable from '../../task-list-table/task-list-table'; +import { IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice'; +import { useAuthService } from '@/hooks/useAuth'; +import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; +import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; +import { ITaskPhase } from '@/types/tasks/taskPhase.types'; +import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; +import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events'; +import { ALPHA_CHANNEL } from '@/shared/constants'; +import useIsProjectManager from '@/hooks/useIsProjectManager'; +import logger from '@/utils/errorLogger'; + +interface TaskGroupProps { + taskGroup: ITaskListGroup; + groupBy: string; + color: string; + activeId?: string | null; +} + +const TaskGroup: React.FC = ({ + taskGroup, + groupBy, + color, + activeId +}) => { + const { t } = useTranslation('task-list-table'); + const dispatch = useAppDispatch(); + const { trackMixpanelEvent } = useMixpanelTracking(); + const isProjectManager = useIsProjectManager(); + const currentSession = useAuthService().getCurrentSession(); + + const [isExpanded, setIsExpanded] = useState(true); + const [isRenaming, setIsRenaming] = useState(false); + const [groupName, setGroupName] = useState(taskGroup.name || ''); + + const { projectId } = useAppSelector((state: any) => state.projectReducer); + const themeMode = useAppSelector((state: any) => state.themeReducer.mode); + + // Memoize droppable configuration + const { setNodeRef } = useDroppable({ + id: taskGroup.id, + data: { + type: 'group', + groupId: taskGroup.id, + }, + }); + + // Memoize task count + const taskCount = useMemo(() => taskGroup.tasks?.length || 0, [taskGroup.tasks]); + + // Memoize dropdown items + const dropdownItems: MenuProps['items'] = useMemo(() => { + if (groupBy !== IGroupBy.STATUS || !isProjectManager) return []; + + return [ + { + key: 'rename', + label: t('renameText'), + icon: , + onClick: () => setIsRenaming(true), + }, + { + key: 'change-category', + label: t('changeCategoryText'), + icon: , + children: [ + { + key: 'todo', + label: t('todoText'), + onClick: () => handleStatusCategoryChange('0'), + }, + { + key: 'doing', + label: t('doingText'), + onClick: () => handleStatusCategoryChange('1'), + }, + { + key: 'done', + label: t('doneText'), + onClick: () => handleStatusCategoryChange('2'), + }, + ], + }, + ]; + }, [groupBy, isProjectManager, t]); + + const handleStatusCategoryChange = async (category: string) => { + if (!projectId || !taskGroup.id) return; + + try { + await statusApiService.updateStatus({ + id: taskGroup.id, + category_id: category, + project_id: projectId, + }); + + dispatch(fetchStatuses()); + trackMixpanelEvent(evt_project_board_column_setting_click, { + column_id: taskGroup.id, + action: 'change_category', + category, + }); + } catch (error) { + logger.error('Error updating status category:', error); + } + }; + + const handleRename = async () => { + if (!projectId || !taskGroup.id || !groupName.trim()) return; + + try { + if (groupBy === IGroupBy.STATUS) { + await statusApiService.updateStatus({ + id: taskGroup.id, + name: groupName.trim(), + project_id: projectId, + }); + dispatch(fetchStatuses()); + } else if (groupBy === IGroupBy.PHASE) { + const phaseData: ITaskPhase = { + id: taskGroup.id, + name: groupName.trim(), + project_id: projectId, + color_code: taskGroup.color_code, + }; + await phasesApiService.updatePhase(phaseData); + dispatch(fetchPhasesByProjectId(projectId)); + } + + setIsRenaming(false); + } catch (error) { + logger.error('Error renaming group:', error); + } + }; + + const handleColorChange = async (newColor: string) => { + if (!projectId || !taskGroup.id) return; + + try { + const baseColor = newColor.endsWith(ALPHA_CHANNEL) + ? newColor.slice(0, -ALPHA_CHANNEL.length) + : newColor; + + if (groupBy === IGroupBy.PHASE) { + const phaseData: ITaskPhase = { + id: taskGroup.id, + name: taskGroup.name || '', + project_id: projectId, + color_code: baseColor, + }; + await phasesApiService.updatePhase(phaseData); + dispatch(fetchPhasesByProjectId(projectId)); + } + + dispatch(updateTaskGroupColor({ + groupId: taskGroup.id, + color: baseColor, + })); + } catch (error) { + logger.error('Error updating group color:', error); + } + }; + + return ( +
+ + {/* Group Header */} + + + + {dropdownItems.length > 0 && !isRenaming && ( + +
+ ); +}; + +export default React.memo(TaskGroup); \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx index 410644fb..29914771 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/project-view-task-list.tsx @@ -4,7 +4,7 @@ import Skeleton from 'antd/es/skeleton'; import { useSearchParams } from 'react-router-dom'; import TaskListFilters from './task-list-filters/task-list-filters'; -import TaskGroupWrapper from './task-list-table/task-group-wrapper/task-group-wrapper'; +import TaskGroupWrapperOptimized from './task-group-wrapper-optimized'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice'; @@ -17,29 +17,50 @@ const ProjectViewTaskList = () => { const dispatch = useAppDispatch(); const { projectView } = useTabSearchParam(); const [searchParams, setSearchParams] = useSearchParams(); - const [isLoading, setIsLoading] = useState(true); const [initialLoadComplete, setInitialLoadComplete] = useState(false); - const { projectId } = useAppSelector(state => state.projectReducer); - const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector( - state => state.taskReducer - ); - const { statusCategories, loading: loadingStatusCategories } = useAppSelector( - state => state.taskStatusReducer - ); - const { loadingPhases } = useAppSelector(state => state.phaseReducer); - const { loadingColumns } = useAppSelector(state => state.taskReducer); + // Combine related selectors to reduce subscriptions + const { + projectId, + taskGroups, + loadingGroups, + groupBy, + archived, + fields, + search, + } = useAppSelector(state => ({ + projectId: state.projectReducer.projectId, + taskGroups: state.taskReducer.taskGroups, + loadingGroups: state.taskReducer.loadingGroups, + groupBy: state.taskReducer.groupBy, + archived: state.taskReducer.archived, + fields: state.taskReducer.fields, + search: state.taskReducer.search, + })); - // Memoize the loading state calculation - ignoring task list filter loading - const isLoadingState = useMemo(() => - loadingGroups || loadingPhases || loadingStatusCategories, - [loadingGroups, loadingPhases, loadingStatusCategories] + const { + statusCategories, + loading: loadingStatusCategories, + } = useAppSelector(state => ({ + statusCategories: state.taskStatusReducer.statusCategories, + loading: state.taskStatusReducer.loading, + })); + + const { loadingPhases } = useAppSelector(state => ({ + loadingPhases: state.phaseReducer.loadingPhases, + })); + + // Single source of truth for loading state - EXCLUDE labels loading from skeleton + // Labels loading should not block the main task list display + const isLoading = useMemo(() => + loadingGroups || loadingPhases || loadingStatusCategories || !initialLoadComplete, + [loadingGroups, loadingPhases, loadingStatusCategories, initialLoadComplete] ); // Memoize the empty state check const isEmptyState = useMemo(() => - taskGroups && taskGroups.length === 0 && !isLoadingState, - [taskGroups, isLoadingState] + taskGroups && taskGroups.length === 0 && !isLoading, + [taskGroups, isLoading] ); // Handle view type changes @@ -50,34 +71,32 @@ const ProjectViewTaskList = () => { newParams.set('pinned_tab', 'tasks-list'); setSearchParams(newParams); } - }, [projectView, setSearchParams]); + }, [projectView, setSearchParams, searchParams]); - // Update loading state - useEffect(() => { - setIsLoading(isLoadingState); - }, [isLoadingState]); - - // Fetch initial data only once + // Batch initial data fetching - core data only useEffect(() => { const fetchInitialData = async () => { if (!projectId || !groupBy || initialLoadComplete) return; try { - await Promise.all([ + // Batch only essential API calls for initial load + // Filter data (labels, assignees, etc.) will load separately and not block the UI + await Promise.allSettled([ dispatch(fetchTaskListColumns(projectId)), dispatch(fetchPhasesByProjectId(projectId)), - dispatch(fetchStatusesCategories()) + dispatch(fetchStatusesCategories()), ]); setInitialLoadComplete(true); } catch (error) { console.error('Error fetching initial data:', error); + setInitialLoadComplete(true); // Still mark as complete to prevent infinite loading } }; fetchInitialData(); }, [projectId, groupBy, dispatch, initialLoadComplete]); - // Fetch task groups + // Fetch task groups with dependency on initial load completion useEffect(() => { const fetchTasks = async () => { if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return; @@ -92,15 +111,22 @@ const ProjectViewTaskList = () => { fetchTasks(); }, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]); + // Memoize the task groups to prevent unnecessary re-renders + const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]); + return ( + {/* Filters load independently and don't block the main content */} {isEmptyState ? ( ) : ( - + )} diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx new file mode 100644 index 00000000..71257305 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import Flex from 'antd/es/flex'; +import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect'; + +import { + DndContext, + pointerWithin, +} from '@dnd-kit/core'; + +import { ITaskListGroup } from '@/types/tasks/taskList.types'; +import { useAppSelector } from '@/hooks/useAppSelector'; + +import TaskListTableWrapper from './task-list-table/task-list-table-wrapper/task-list-table-wrapper'; +import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar'; +import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer'; + +import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; +import { useTaskDragAndDrop } from '@/hooks/useTaskDragAndDrop'; + +interface TaskGroupWrapperOptimizedProps { + taskGroups: ITaskListGroup[]; + groupBy: string; +} + +const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOptimizedProps) => { + const themeMode = useAppSelector((state: any) => state.themeReducer.mode); + + // Use extracted hooks + useTaskSocketHandlers(); + const { + activeId, + sensors, + handleDragStart, + handleDragEnd, + handleDragOver, + resetTaskRowStyles, + } = useTaskDragAndDrop({ taskGroups, groupBy }); + + // Memoize task groups with colors + const taskGroupsWithColors = useMemo(() => + taskGroups?.map(taskGroup => ({ + ...taskGroup, + displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code, + })) || [], + [taskGroups, themeMode] + ); + + // Add drag styles + useEffect(() => { + const style = document.createElement('style'); + style.textContent = ` + .task-row[data-is-dragging="true"] { + opacity: 0.5 !important; + transform: rotate(5deg) !important; + z-index: 1000 !important; + position: relative !important; + } + .task-row { + transition: transform 0.2s ease, opacity 0.2s ease; + } + `; + document.head.appendChild(style); + + return () => { + document.head.removeChild(style); + }; + }, []); + + // Handle animation cleanup after drag ends + useIsomorphicLayoutEffect(() => { + if (activeId === null) { + const timeoutId = setTimeout(resetTaskRowStyles, 50); + return () => clearTimeout(timeoutId); + } + }, [activeId, resetTaskRowStyles]); + + return ( + + + {taskGroupsWithColors.map(taskGroup => ( + + ))} + + {createPortal(, document.body, 'bulk-action-container')} + + {createPortal( + {}} />, + document.body, + 'task-template-drawer' + )} + + + ); +}; + +export default React.memo(TaskGroupWrapperOptimized); \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx index c32153b8..fcf866f1 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-filters/task-list-filters.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'; import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useFilterDataLoader } from '@/hooks/useFilterDataLoader'; import { fetchLabelsByProject, fetchTaskAssignees, @@ -33,23 +34,49 @@ const TaskListFilters: React.FC = ({ position }) => { const { projectView } = useTabSearchParam(); const priorities = useAppSelector(state => state.priorityReducer.priorities); - const projectId = useAppSelector(state => state.projectReducer.projectId); const archived = useAppSelector(state => state.taskReducer.archived); const handleShowArchivedChange = () => dispatch(toggleArchived()); + // Load filter data asynchronously and non-blocking + // This runs independently of the main task list loading useEffect(() => { - const fetchInitialData = async () => { - if (!priorities.length) await dispatch(fetchPriorities()); - if (projectId) { - await dispatch(fetchLabelsByProject(projectId)); - await dispatch(fetchTaskAssignees(projectId)); + const loadFilterData = async () => { + try { + // Load priorities first (usually cached/fast) + if (!priorities.length) { + dispatch(fetchPriorities()); + } + + // Load project-specific filter data in parallel, but don't await + // This allows the main task list to load while filters are still loading + if (projectId) { + // Fire and forget - these will update the UI when ready + dispatch(fetchLabelsByProject(projectId)); + dispatch(fetchTaskAssignees(projectId)); + } + + // Load team members (usually needed for member filters) + dispatch(getTeamMembers({ + index: 0, + size: 100, + field: null, + order: null, + search: null, + all: true + })); + } catch (error) { + console.error('Error loading filter data:', error); + // Don't throw - filter loading errors shouldn't break the main UI } - dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true })); }; - fetchInitialData(); + // Use setTimeout to ensure this runs after the main component render + // This prevents filter loading from blocking the initial render + const timeoutId = setTimeout(loadFilterData, 0); + + return () => clearTimeout(timeoutId); }, [dispatch, priorities.length, projectId]); return ( From 5e4d78c6f5d434bbe6c0f144776556bc432f4bd1 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 2 Jun 2025 09:19:58 +0530 Subject: [PATCH 28/35] refactor(task-details-form): enhance progress input handling and improve assignee rendering - Added `InlineMember` type import for better type management. - Enhanced the `Avatars` component to handle multiple sources for assignee names, improving flexibility in data handling. --- .../shared/info-tab/task-details-form.tsx | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx index a2dcaef1..23dac128 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/task-details-form.tsx @@ -30,6 +30,7 @@ import TaskDrawerProgress from './details/task-drawer-progress/task-drawer-progr import { useAppSelector } from '@/hooks/useAppSelector'; import logger from '@/utils/errorLogger'; import TaskDrawerRecurringConfig from './details/task-drawer-recurring-config/task-drawer-recurring-config'; +import { InlineMember } from '@/types/teamMembers/inlineMember.types'; interface TaskDetailsFormProps { taskFormViewModel?: ITaskFormViewModel | null; @@ -45,29 +46,32 @@ const ConditionalProgressInput = ({ task, form }: ConditionalProgressInputProps) const { project } = useAppSelector(state => state.projectReducer); const hasSubTasks = task?.sub_tasks_count > 0; const isSubTask = !!task?.parent_task_id; - - // Add more aggressive logging and checks - logger.debug(`Task ${task.id} status: hasSubTasks=${hasSubTasks}, isSubTask=${isSubTask}, modes: time=${project?.use_time_progress}, manual=${project?.use_manual_progress}, weighted=${project?.use_weighted_progress}`); - + // STRICT RULE: Never show progress input for parent tasks with subtasks // This is the most important check and must be done first if (hasSubTasks) { logger.debug(`Task ${task.id} has ${task.sub_tasks_count} subtasks. Hiding progress input.`); return null; } - + // Only for tasks without subtasks, determine which input to show based on project mode if (project?.use_time_progress) { // In time-based mode, show progress input ONLY for tasks without subtasks - return ; + return ( + + ); } else if (project?.use_manual_progress) { // In manual mode, show progress input ONLY for tasks without subtasks - return ; + return ( + + ); } else if (project?.use_weighted_progress && isSubTask) { // In weighted mode, show weight input for subtasks - return ; + return ( + + ); } - + return null; }; @@ -148,7 +152,13 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => - + @@ -160,10 +170,7 @@ const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => {taskFormViewModel?.task && ( - + )} From 24fa837a39ba4fc5e95d22724b4404c9290138ef Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 2 Jun 2025 13:07:50 +0530 Subject: [PATCH 29/35] feat(auth): enhance login and verification processes with detailed debug logging - Added comprehensive debug logging to the login strategy and verification endpoint to track authentication flow and errors. - Improved title determination logic for login and signup success/failure messages based on authentication status. - Implemented middleware for logging request details on the login route to aid in debugging. --- .../src/controllers/auth-controller.ts | 28 +++++++++++++-- .../passport-local-login.ts | 36 +++++++++++++++---- worklenz-backend/src/routes/auth/index.ts | 14 +++++++- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/worklenz-backend/src/controllers/auth-controller.ts b/worklenz-backend/src/controllers/auth-controller.ts index 8364d59c..b2d24c16 100644 --- a/worklenz-backend/src/controllers/auth-controller.ts +++ b/worklenz-backend/src/controllers/auth-controller.ts @@ -35,8 +35,32 @@ export default class AuthController extends WorklenzControllerBase { const auth_error = errors.length > 0 ? errors[0] : null; const message = messages.length > 0 ? messages[0] : null; - const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!"; - const title = req.query.strategy ? midTitle : null; + // Debug logging + console.log("=== VERIFY ENDPOINT HIT ==="); + console.log("Verify endpoint - Strategy:", req.query.strategy); + console.log("Verify endpoint - Authenticated:", req.isAuthenticated()); + console.log("Verify endpoint - User:", !!req.user); + console.log("Verify endpoint - User ID:", req.user?.id); + console.log("Verify endpoint - Auth error:", auth_error); + console.log("Verify endpoint - Success message:", message); + console.log("Verify endpoint - Flash errors:", errors); + console.log("Verify endpoint - Flash messages:", messages); + console.log("Verify endpoint - Session ID:", req.sessionID); + console.log("Verify endpoint - Session passport:", (req.session as any).passport); + console.log("Verify endpoint - Session flash:", (req.session as any).flash); + + // Determine title based on authentication status and strategy + let title = null; + if (req.query.strategy) { + if (auth_error) { + // Show failure title only when there's an actual error + title = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!"; + } else if (req.isAuthenticated() && message) { + // Show success title when authenticated and there's a success message + title = req.query.strategy === "login" ? "Login Successful!" : "Signup Successful!"; + } + // If no error and not authenticated, don't show any title (this might be a redirect without completion) + } if (req.user) req.user.build_v = FileConstants.getRelease(); diff --git a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts index 7d29fae8..f399b326 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts @@ -3,13 +3,23 @@ import { Strategy as LocalStrategy } from "passport-local"; import { log_error } from "../../shared/utils"; import db from "../../config/db"; import { Request } from "express"; +import { ERROR_KEY, SUCCESS_KEY } from "./passport-constants"; async function handleLogin(req: Request, email: string, password: string, done: any) { + console.log("=== LOGIN STRATEGY STARTED ==="); console.log("Login attempt for:", email); + console.log("Password provided:", !!password); + console.log("Request body:", req.body); + + // Clear any existing flash messages + (req.session as any).flash = {}; if (!email || !password) { - console.log("Missing credentials"); - return done(null, false, { message: "Please enter both email and password" }); + console.log("Missing credentials - email:", !!email, "password:", !!password); + const errorMsg = "Please enter both email and password"; + console.log("Setting error flash message:", errorMsg); + req.flash(ERROR_KEY, errorMsg); + return done(null, false); } try { @@ -24,18 +34,30 @@ async function handleLogin(req: Request, email: string, password: string, done: const [data] = result.rows; if (!data?.password) { - console.log("No account found"); - return done(null, false, { message: "No account found with this email" }); + console.log("No account found for email:", email); + const errorMsg = "No account found with this email"; + console.log("Setting error flash message:", errorMsg); + req.flash(ERROR_KEY, errorMsg); + return done(null, false); } const passwordMatch = bcrypt.compareSync(password, data.password); - console.log("Password match:", passwordMatch); + console.log("Password match result:", passwordMatch); if (passwordMatch && email === data.email) { delete data.password; - return done(null, data, {message: "User successfully logged in"}); + console.log("Login successful for user:", data.id); + const successMsg = "User successfully logged in"; + console.log("Setting success flash message:", successMsg); + req.flash(SUCCESS_KEY, successMsg); + return done(null, data); } - return done(null, false, { message: "Incorrect email or password" }); + + console.log("Password mismatch or email mismatch"); + const errorMsg = "Incorrect email or password"; + console.log("Setting error flash message:", errorMsg); + req.flash(ERROR_KEY, errorMsg); + return done(null, false); } catch (error) { console.error("Login error:", error); log_error(error, req.body); diff --git a/worklenz-backend/src/routes/auth/index.ts b/worklenz-backend/src/routes/auth/index.ts index 1d34fb27..5c57d314 100644 --- a/worklenz-backend/src/routes/auth/index.ts +++ b/worklenz-backend/src/routes/auth/index.ts @@ -17,7 +17,19 @@ const options = (key: string): passport.AuthenticateOptions => ({ successRedirect: `/secure/verify?strategy=${key}` }); -authRouter.post("/login", passport.authenticate("local-login", options("login"))); +// Debug middleware for login +const loginDebugMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => { + console.log("=== LOGIN ROUTE HIT ==="); + console.log("Request method:", req.method); + console.log("Request URL:", req.url); + console.log("Request body:", req.body); + console.log("Content-Type:", req.headers["content-type"]); + console.log("Session ID:", req.sessionID); + console.log("Is authenticated before:", req.isAuthenticated()); + next(); +}; + +authRouter.post("/login", loginDebugMiddleware, passport.authenticate("local-login", options("login"))); authRouter.post("/signup", signUpValidator, passwordValidator, passport.authenticate("local-signup", options("signup"))); authRouter.post("/signup/check", signUpValidator, passwordValidator, safeControllerFunction(AuthController.status_check)); authRouter.get("/verify", AuthController.verify); From 69f50095795d054702cfa02175b58cc3584b8fdb Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 2 Jun 2025 13:20:40 +0530 Subject: [PATCH 30/35] refactor(auth): remove debug logging and enhance session middleware - Eliminated extensive debug logging from the login strategy and verification endpoint to streamline the authentication process. - Updated session middleware to improve cookie handling, enabling proxy support and adjusting session creation behavior. - Ensured secure cookie settings for cross-origin requests in production environments. --- .../src/controllers/auth-controller.ts | 14 -------------- .../src/middlewares/session-middleware.ts | 17 ++++++++++------- .../passport-strategies/passport-local-login.ts | 15 --------------- worklenz-backend/src/routes/auth/index.ts | 14 +------------- 4 files changed, 11 insertions(+), 49 deletions(-) diff --git a/worklenz-backend/src/controllers/auth-controller.ts b/worklenz-backend/src/controllers/auth-controller.ts index b2d24c16..4fea4f59 100644 --- a/worklenz-backend/src/controllers/auth-controller.ts +++ b/worklenz-backend/src/controllers/auth-controller.ts @@ -35,20 +35,6 @@ export default class AuthController extends WorklenzControllerBase { const auth_error = errors.length > 0 ? errors[0] : null; const message = messages.length > 0 ? messages[0] : null; - // Debug logging - console.log("=== VERIFY ENDPOINT HIT ==="); - console.log("Verify endpoint - Strategy:", req.query.strategy); - console.log("Verify endpoint - Authenticated:", req.isAuthenticated()); - console.log("Verify endpoint - User:", !!req.user); - console.log("Verify endpoint - User ID:", req.user?.id); - console.log("Verify endpoint - Auth error:", auth_error); - console.log("Verify endpoint - Success message:", message); - console.log("Verify endpoint - Flash errors:", errors); - console.log("Verify endpoint - Flash messages:", messages); - console.log("Verify endpoint - Session ID:", req.sessionID); - console.log("Verify endpoint - Session passport:", (req.session as any).passport); - console.log("Verify endpoint - Session flash:", (req.session as any).flash); - // Determine title based on authentication status and strategy let title = null; if (req.query.strategy) { diff --git a/worklenz-backend/src/middlewares/session-middleware.ts b/worklenz-backend/src/middlewares/session-middleware.ts index cb6cd624..a0452bee 100644 --- a/worklenz-backend/src/middlewares/session-middleware.ts +++ b/worklenz-backend/src/middlewares/session-middleware.ts @@ -5,12 +5,15 @@ import { isProduction } from "../shared/utils"; // eslint-disable-next-line @typescript-eslint/no-var-requires const pgSession = require("connect-pg-simple")(session); +// For cross-origin requests, we need special cookie settings +const isHttps = process.env.NODE_ENV === "production" || process.env.FORCE_HTTPS === "true"; + export default session({ - name: process.env.SESSION_NAME, + name: process.env.SESSION_NAME || "worklenz.sid", secret: process.env.SESSION_SECRET || "development-secret-key", - proxy: false, + proxy: true, // Enable proxy support for proper session handling resave: false, - saveUninitialized: true, + saveUninitialized: false, // Changed to false to prevent unnecessary session creation rolling: true, store: new pgSession({ pool: db.pool, @@ -18,10 +21,10 @@ export default session({ }), cookie: { path: "/", - // secure: isProduction(), - // httpOnly: isProduction(), - // sameSite: "none", - // domain: isProduction() ? ".worklenz.com" : undefined, + secure: isHttps, // Only secure in production with HTTPS + httpOnly: true, // Enable httpOnly for security + sameSite: isHttps ? "none" : false, // Use "none" for HTTPS cross-origin, disable for HTTP + domain: undefined, // Don't set domain for cross-origin requests maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days } }); \ No newline at end of file diff --git a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts index f399b326..d71c4a36 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts @@ -6,18 +6,11 @@ import { Request } from "express"; import { ERROR_KEY, SUCCESS_KEY } from "./passport-constants"; async function handleLogin(req: Request, email: string, password: string, done: any) { - console.log("=== LOGIN STRATEGY STARTED ==="); - console.log("Login attempt for:", email); - console.log("Password provided:", !!password); - console.log("Request body:", req.body); - // Clear any existing flash messages (req.session as any).flash = {}; if (!email || !password) { - console.log("Missing credentials - email:", !!email, "password:", !!password); const errorMsg = "Please enter both email and password"; - console.log("Setting error flash message:", errorMsg); req.flash(ERROR_KEY, errorMsg); return done(null, false); } @@ -29,33 +22,25 @@ async function handleLogin(req: Request, email: string, password: string, done: AND google_id IS NULL AND is_deleted IS FALSE;`; const result = await db.query(q, [email]); - console.log("User query result count:", result.rowCount); const [data] = result.rows; if (!data?.password) { - console.log("No account found for email:", email); const errorMsg = "No account found with this email"; - console.log("Setting error flash message:", errorMsg); req.flash(ERROR_KEY, errorMsg); return done(null, false); } const passwordMatch = bcrypt.compareSync(password, data.password); - console.log("Password match result:", passwordMatch); if (passwordMatch && email === data.email) { delete data.password; - console.log("Login successful for user:", data.id); const successMsg = "User successfully logged in"; - console.log("Setting success flash message:", successMsg); req.flash(SUCCESS_KEY, successMsg); return done(null, data); } - console.log("Password mismatch or email mismatch"); const errorMsg = "Incorrect email or password"; - console.log("Setting error flash message:", errorMsg); req.flash(ERROR_KEY, errorMsg); return done(null, false); } catch (error) { diff --git a/worklenz-backend/src/routes/auth/index.ts b/worklenz-backend/src/routes/auth/index.ts index 5c57d314..1d34fb27 100644 --- a/worklenz-backend/src/routes/auth/index.ts +++ b/worklenz-backend/src/routes/auth/index.ts @@ -17,19 +17,7 @@ const options = (key: string): passport.AuthenticateOptions => ({ successRedirect: `/secure/verify?strategy=${key}` }); -// Debug middleware for login -const loginDebugMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => { - console.log("=== LOGIN ROUTE HIT ==="); - console.log("Request method:", req.method); - console.log("Request URL:", req.url); - console.log("Request body:", req.body); - console.log("Content-Type:", req.headers["content-type"]); - console.log("Session ID:", req.sessionID); - console.log("Is authenticated before:", req.isAuthenticated()); - next(); -}; - -authRouter.post("/login", loginDebugMiddleware, passport.authenticate("local-login", options("login"))); +authRouter.post("/login", passport.authenticate("local-login", options("login"))); authRouter.post("/signup", signUpValidator, passwordValidator, passport.authenticate("local-signup", options("signup"))); authRouter.post("/signup/check", signUpValidator, passwordValidator, safeControllerFunction(AuthController.status_check)); authRouter.get("/verify", AuthController.verify); From cfa0af24aeb99cfaee0028b1250cb0d91f3bd397 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 2 Jun 2025 13:29:05 +0530 Subject: [PATCH 31/35] refactor(session-middleware): improve cookie handling and security settings - Updated session middleware to use secure cookies in production environments. - Adjusted sameSite attribute to "lax" for standard handling of same-origin requests. - Removed unnecessary comments and streamlined cookie settings for clarity. --- .../src/middlewares/session-middleware.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/worklenz-backend/src/middlewares/session-middleware.ts b/worklenz-backend/src/middlewares/session-middleware.ts index a0452bee..fea60018 100644 --- a/worklenz-backend/src/middlewares/session-middleware.ts +++ b/worklenz-backend/src/middlewares/session-middleware.ts @@ -5,15 +5,12 @@ import { isProduction } from "../shared/utils"; // eslint-disable-next-line @typescript-eslint/no-var-requires const pgSession = require("connect-pg-simple")(session); -// For cross-origin requests, we need special cookie settings -const isHttps = process.env.NODE_ENV === "production" || process.env.FORCE_HTTPS === "true"; - export default session({ name: process.env.SESSION_NAME || "worklenz.sid", secret: process.env.SESSION_SECRET || "development-secret-key", - proxy: true, // Enable proxy support for proper session handling + proxy: true, resave: false, - saveUninitialized: false, // Changed to false to prevent unnecessary session creation + saveUninitialized: false, rolling: true, store: new pgSession({ pool: db.pool, @@ -21,10 +18,9 @@ export default session({ }), cookie: { path: "/", - secure: isHttps, // Only secure in production with HTTPS - httpOnly: true, // Enable httpOnly for security - sameSite: isHttps ? "none" : false, // Use "none" for HTTPS cross-origin, disable for HTTP - domain: undefined, // Don't set domain for cross-origin requests + secure: isProduction(), // Use secure cookies in production + httpOnly: true, + sameSite: "lax", // Standard setting for same-origin requests maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days } }); \ No newline at end of file From 09f44a5685e1b258efe8da9427be75e9d745e3db Mon Sep 17 00:00:00 2001 From: kithmina1999 Date: Thu, 5 Jun 2025 10:40:06 +0530 Subject: [PATCH 32/35] fix: change DB_PASSWORD to static value for development Using a static password simplifies development environment setup. The previous random password generation caused issues during local testing and debugging. --- update-docker-env.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/update-docker-env.sh b/update-docker-env.sh index 12044bd1..7852e86f 100755 --- a/update-docker-env.sh +++ b/update-docker-env.sh @@ -92,7 +92,7 @@ LOGIN_SUCCESS_REDIRECT="${FRONTEND_URL}/auth/authenticating" DB_HOST=db DB_PORT=5432 DB_USER=postgres -DB_PASSWORD=$(openssl rand -base64 48) +DB_PASSWORD=password DB_NAME=worklenz_db DB_MAX_CLIENTS=50 USE_PG_NATIVE=true From bd7773393503e72f1617d8b3489fb9d76001f276 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 5 Jun 2025 11:11:16 +0530 Subject: [PATCH 33/35] feat(timers): add running timers feature to the navbar - Introduced a new `TimerButton` component to display and manage running timers. - Implemented API service method `getRunningTimers` to fetch active timers. - Updated the navbar to replace the HelpButton with the TimerButton for better functionality. - Enhanced timer display with real-time updates and socket event handling for timer start/stop actions. --- .../api/tasks/task-time-logs.api.service.ts | 15 + .../src/features/navbar/navbar.tsx | 5 +- .../features/navbar/timers/timer-button.tsx | 275 ++++++++++++++++++ 3 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 worklenz-frontend/src/features/navbar/timers/timer-button.tsx diff --git a/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts b/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts index f4565837..37673590 100644 --- a/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts +++ b/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts @@ -5,6 +5,16 @@ import { ITaskLogViewModel } from "@/types/tasks/task-log-view.types"; const rootUrl = `${API_BASE_URL}/task-time-log`; +export interface IRunningTimer { + task_id: string; + start_time: string; + task_name: string; + project_id: string; + project_name: string; + parent_task_id?: string; + parent_task_name?: string; +} + export const taskTimeLogsApiService = { getByTask: async (id: string) : Promise> => { const response = await apiClient.get(`${rootUrl}/task/${id}`); @@ -26,6 +36,11 @@ export const taskTimeLogsApiService = { return response.data; }, + getRunningTimers: async (): Promise> => { + const response = await apiClient.get(`${rootUrl}/running-timers`); + return response.data; + }, + exportToExcel(taskId: string) { window.location.href = `${import.meta.env.VITE_API_URL}${API_BASE_URL}/task-time-log/export/${taskId}`; }, diff --git a/worklenz-frontend/src/features/navbar/navbar.tsx b/worklenz-frontend/src/features/navbar/navbar.tsx index 41d7c0e7..430318d3 100644 --- a/worklenz-frontend/src/features/navbar/navbar.tsx +++ b/worklenz-frontend/src/features/navbar/navbar.tsx @@ -5,7 +5,6 @@ import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from 'antd'; import { createPortal } from 'react-dom'; import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members'; -import HelpButton from './help/HelpButton'; import InviteButton from './invite/InviteButton'; import MobileMenuButton from './mobileMenu/MobileMenuButton'; import NavbarLogo from './navbar-logo'; @@ -22,6 +21,7 @@ import { useAuthService } from '@/hooks/useAuth'; import { authApiService } from '@/api/auth/auth.api.service'; import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; import logger from '@/utils/errorLogger'; +import TimerButton from './timers/timer-button'; const Navbar = () => { const [current, setCurrent] = useState('home'); @@ -90,6 +90,7 @@ const Navbar = () => { }, [location]); return ( + { - + diff --git a/worklenz-frontend/src/features/navbar/timers/timer-button.tsx b/worklenz-frontend/src/features/navbar/timers/timer-button.tsx new file mode 100644 index 00000000..c4a229e8 --- /dev/null +++ b/worklenz-frontend/src/features/navbar/timers/timer-button.tsx @@ -0,0 +1,275 @@ +import { ClockCircleOutlined, StopOutlined } from '@ant-design/icons'; +import { Badge, Button, Dropdown, List, Tooltip, Typography, Space, Divider, theme } from 'antd'; +import React, { useEffect, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { taskTimeLogsApiService, IRunningTimer } from '@/api/tasks/task-time-logs.api.service'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice'; +import moment from 'moment'; + +const { Text } = Typography; +const { useToken } = theme; + +const TimerButton = () => { + const [runningTimers, setRunningTimers] = useState([]); + const [loading, setLoading] = useState(false); + const [currentTimes, setCurrentTimes] = useState>({}); + const [dropdownOpen, setDropdownOpen] = useState(false); + const { t } = useTranslation('navbar'); + const { token } = useToken(); + const dispatch = useAppDispatch(); + const { socket } = useSocket(); + + const fetchRunningTimers = useCallback(async () => { + try { + setLoading(true); + const response = await taskTimeLogsApiService.getRunningTimers(); + if (response.done) { + setRunningTimers(response.body || []); + } + } catch (error) { + console.error('Error fetching running timers:', error); + } finally { + setLoading(false); + } + }, []); + + const updateCurrentTimes = () => { + const newTimes: Record = {}; + runningTimers.forEach(timer => { + const startTime = moment(timer.start_time); + const now = moment(); + const duration = moment.duration(now.diff(startTime)); + const hours = Math.floor(duration.asHours()); + const minutes = duration.minutes(); + const seconds = duration.seconds(); + newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + }); + setCurrentTimes(newTimes); + }; + + useEffect(() => { + fetchRunningTimers(); + + // Set up polling to refresh timers every 30 seconds + const pollInterval = setInterval(() => { + fetchRunningTimers(); + }, 30000); + + return () => clearInterval(pollInterval); + }, [fetchRunningTimers]); + + useEffect(() => { + if (runningTimers.length > 0) { + updateCurrentTimes(); + const interval = setInterval(updateCurrentTimes, 1000); + return () => clearInterval(interval); + } + }, [runningTimers]); + + // Listen for timer start/stop events and project updates to refresh the count + useEffect(() => { + if (!socket) return; + + const handleTimerStart = (data: string) => { + try { + const { id } = typeof data === 'string' ? JSON.parse(data) : data; + if (id) { + // Refresh the running timers list when a new timer is started + fetchRunningTimers(); + } + } catch (error) { + console.error('Error parsing timer start event:', error); + } + }; + + const handleTimerStop = (data: string) => { + try { + const { id } = typeof data === 'string' ? JSON.parse(data) : data; + if (id) { + // Refresh the running timers list when a timer is stopped + fetchRunningTimers(); + } + } catch (error) { + console.error('Error parsing timer stop event:', error); + } + }; + + const handleProjectUpdates = () => { + // Refresh timers when project updates are available + fetchRunningTimers(); + }; + + socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); + socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); + socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); + + return () => { + socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); + socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); + socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); + }; + }, [socket, fetchRunningTimers]); + + const hasRunningTimers = () => { + return runningTimers.length > 0; + }; + + const timerCount = () => { + return runningTimers.length; + }; + + const handleStopTimer = (taskId: string) => { + if (!socket) return; + + socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId })); + dispatch(updateTaskTimeTracking({ taskId, timeTracking: null })); + }; + + const dropdownContent = ( +
+ {runningTimers.length === 0 ? ( +
+ No running timers +
+ ) : ( + ( + +
+ + + {timer.task_name} + +
+ {timer.project_name} +
+ {timer.parent_task_name && ( + + Parent: {timer.parent_task_name} + + )} +
+
+
+ + Started: {moment(timer.start_time).format('HH:mm')} + + + {currentTimes[timer.task_id] || '00:00:00'} + +
+
+ +
+
+
+
+ )} + /> + )} + {runningTimers.length > 0 && ( + <> + +
+ + {runningTimers.length} timer{runningTimers.length !== 1 ? 's' : ''} running + +
+ + )} +
+ ); + + return ( + dropdownContent} + trigger={['click']} + placement="bottomRight" + open={dropdownOpen} + onOpenChange={(open) => { + setDropdownOpen(open); + if (open) { + fetchRunningTimers(); + } + }} + > + +