diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx
index 0f29cdcd..7999b2d7 100644
--- a/worklenz-frontend/src/App.tsx
+++ b/worklenz-frontend/src/App.tsx
@@ -1,7 +1,8 @@
// Core dependencies
-import React, { Suspense, useEffect, memo, useMemo, useCallback } from 'react';
+import React, { Suspense, useEffect, memo, useMemo, useCallback, useState } from 'react';
import { RouterProvider } from 'react-router-dom';
import i18next from 'i18next';
+import { useTranslation } from 'react-i18next';
// Components
import ThemeWrapper from './features/theme/ThemeWrapper';
@@ -27,6 +28,24 @@ import { CSSPerformanceMonitor, LayoutStabilizer, CriticalCSSManager } from './u
// Service Worker
import { registerSW } from './utils/serviceWorkerRegistration';
+// i18n ready check component
+const I18nReadyCheck: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const { ready } = useTranslation();
+ const [isReady, setIsReady] = useState(false);
+
+ useEffect(() => {
+ if (ready) {
+ setIsReady(true);
+ }
+ }, [ready]);
+
+ if (!isReady) {
+ return ;
+ }
+
+ return <>{children}>;
+};
+
/**
* Main App Component - Performance Optimized
*
@@ -200,18 +219,15 @@ const App: React.FC = memo(() => {
}, []);
return (
- }>
-
-
-
-
-
-
+
+
+
+ }>
+
+
+
+
+
);
});
diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts
index 4a5b8848..8f1ebfa1 100644
--- a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts
+++ b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts
@@ -250,6 +250,30 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
newPosition: insertIndex,
});
+ // Optimistically update task properties immediately for UI responsiveness
+ const updatedTask = {
+ ...activeTask,
+ updatedAt: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ };
+
+ // Update task properties based on the grouping type
+ if (currentGrouping === 'priority') {
+ updatedTask.priority = targetGroup.id;
+ updatedTask.priority_id = targetGroup.id;
+ } else if (currentGrouping === 'phase') {
+ updatedTask.phase = targetGroup.id;
+ updatedTask.phase_id = targetGroup.id;
+ } else if (currentGrouping === 'status') {
+ updatedTask.status = targetGroup.id;
+ updatedTask.status_id = targetGroup.id;
+ }
+
+ // Import and dispatch updateTask for immediate UI update
+ import('@/features/task-management/task-management.slice').then(({ updateTask }) => {
+ dispatch(updateTask(updatedTask));
+ });
+
// reorderTasksInGroup handles both same-group and cross-group moves
// No need for separate moveTaskBetweenGroups call
dispatch(
diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts
index f0e9c494..2c123f3b 100644
--- a/worklenz-frontend/src/features/task-management/task-management.slice.ts
+++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts
@@ -657,9 +657,15 @@ const taskManagementSlice = createSlice({
const group = state.groups.find(g => g.id === sourceGroupId);
if (group) {
const newTasks = Array.from(group.taskIds);
- const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1);
- newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed);
- group.taskIds = newTasks;
+ const sourceIndex = newTasks.indexOf(sourceTaskId);
+ const destIndex = newTasks.indexOf(destinationTaskId);
+
+ // Only proceed if both tasks are found
+ if (sourceIndex !== -1 && destIndex !== -1) {
+ const [removed] = newTasks.splice(sourceIndex, 1);
+ newTasks.splice(destIndex, 0, removed);
+ group.taskIds = newTasks;
+ }
// Update order for affected tasks using the appropriate sort field
const sortField = getSortOrderField(state.grouping?.id);
@@ -681,13 +687,20 @@ const taskManagementSlice = createSlice({
// Add to destination group at the correct position relative to destinationTask
const destinationIndex = destinationGroup.taskIds.indexOf(destinationTaskId);
if (destinationIndex !== -1) {
- destinationGroup.taskIds.splice(destinationIndex, 0, sourceTaskId);
+ // Ensure we don't add duplicate task IDs
+ if (!destinationGroup.taskIds.includes(sourceTaskId)) {
+ destinationGroup.taskIds.splice(destinationIndex, 0, sourceTaskId);
+ }
} else {
- destinationGroup.taskIds.push(sourceTaskId); // Add to end if destination task not found
+ // Add to end if destination task not found, but only if not already present
+ if (!destinationGroup.taskIds.includes(sourceTaskId)) {
+ destinationGroup.taskIds.push(sourceTaskId);
+ }
}
- // Do NOT update the task's grouping field (priority, phase, status) here.
- // This will be handled by the socket event handler after backend confirmation.
+ // Note: Task's grouping field (priority, phase, status) will also be updated
+ // by the socket event handler after backend confirmation, but for immediate UI
+ // feedback, we update it optimistically in the drag and drop handler.
// Update order for affected tasks in both groups using the appropriate sort field
const sortField = getSortOrderField(state.grouping?.id);
diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts
index 2cb419a9..5655dda2 100644
--- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts
+++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts
@@ -217,6 +217,17 @@ export const useTaskSocketHandlers = () => {
const currentGrouping = state.taskManagement.grouping;
if (currentTask) {
+ // Check if current task has a more recent optimistic update
+ const currentUpdateTime = currentTask.updatedAt || currentTask.updated_at;
+ const serverUpdateTime = response.updated_at || response.updatedAt;
+
+ // If current task was updated more recently than server data, skip this update
+ if (currentUpdateTime && serverUpdateTime &&
+ new Date(currentUpdateTime).getTime() > new Date(serverUpdateTime).getTime()) {
+ console.log(`[TASK_STATUS_CHANGE] Skipping stale update for task ${response.id}`);
+ return;
+ }
+
// Determine the new status value based on status category
let newStatusValue: 'todo' | 'doing' | 'done' = 'todo';
if (response.statusCategory) {
@@ -393,6 +404,17 @@ export const useTaskSocketHandlers = () => {
const currentGrouping = state.taskManagement.grouping;
if (currentTask) {
+ // Check if current task has a more recent optimistic update
+ const currentUpdateTime = currentTask.updatedAt || currentTask.updated_at;
+ const serverUpdateTime = response.updated_at || response.updatedAt;
+
+ // If current task was updated more recently than server data, skip this update
+ if (currentUpdateTime && serverUpdateTime &&
+ new Date(currentUpdateTime).getTime() > new Date(serverUpdateTime).getTime()) {
+ console.log(`[TASK_PRIORITY_CHANGE] Skipping stale update for task ${response.id}`);
+ return;
+ }
+
// Get priority list to map priority_id to priority name
const priorityList = state.priorityReducer?.priorities || [];
let newPriorityValue: 'critical' | 'high' | 'medium' | 'low' = 'medium';
@@ -549,6 +571,17 @@ export const useTaskSocketHandlers = () => {
const currentTask = state.taskManagement.entities[taskId];
if (currentTask) {
+ // Check if current task has a more recent optimistic update
+ const currentUpdateTime = currentTask.updatedAt || currentTask.updated_at;
+ const serverUpdateTime = data.updated_at || data.updatedAt;
+
+ // If current task was updated more recently than server data, skip this update
+ if (currentUpdateTime && serverUpdateTime &&
+ new Date(currentUpdateTime).getTime() > new Date(serverUpdateTime).getTime()) {
+ console.log(`[TASK_PHASE_CHANGE] Skipping stale update for task ${data.task_id}`);
+ return;
+ }
+
// Get phase list to map phase_id to phase name
const phaseList = state.phaseReducer?.phaseList || [];
let newPhaseValue = '';
@@ -1007,6 +1040,18 @@ export const useTaskSocketHandlers = () => {
data.forEach((taskData: any) => {
const currentTask = state.taskManagement.entities[taskData.id];
if (currentTask) {
+ // Check if current task has a more recent optimistic update
+ const currentUpdateTime = currentTask.updatedAt || currentTask.updated_at;
+ const serverUpdateTime = taskData.updated_at || taskData.updatedAt;
+
+ // If current task was updated more recently than server data, skip this update
+ // This prevents socket updates from overwriting newer optimistic updates
+ if (currentUpdateTime && serverUpdateTime &&
+ new Date(currentUpdateTime).getTime() > new Date(serverUpdateTime).getTime()) {
+ console.log(`[TASK_SORT_ORDER_CHANGE] Skipping stale update for task ${taskData.id}`);
+ return;
+ }
+
let updatedTask: Task = {
...currentTask,
order: taskData.sort_order || taskData.current_sort_order || currentTask.order,
diff --git a/worklenz-frontend/src/i18n.ts b/worklenz-frontend/src/i18n.ts
index c7337343..8cff01f2 100644
--- a/worklenz-frontend/src/i18n.ts
+++ b/worklenz-frontend/src/i18n.ts
@@ -24,11 +24,33 @@ i18n
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
+ // Ensure translations are loaded synchronously
+ crossDomain: false,
+ withCredentials: false,
},
react: {
useSuspense: false,
},
+
+ // Ensure all namespaces are loaded upfront
+ ns: ['common', 'home', 'task-management', 'task-list-table'],
+
+ // Add initialization promise to ensure translations are loaded
+ initImmediate: false,
});
+// Ensure translations are loaded before the app starts
+i18n.on('initialized', () => {
+ console.log('i18n initialized successfully');
+});
+
+i18n.on('loaded', (loaded) => {
+ console.log('i18n loaded:', loaded);
+});
+
+i18n.on('failedLoading', (lng, ns, msg) => {
+ console.error('i18n failed loading:', lng, ns, msg);
+});
+
export default i18n;
diff --git a/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx b/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx
index caff5f7f..440eb476 100644
--- a/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx
+++ b/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx
@@ -14,6 +14,8 @@ import { IMyTask } from '@/types/home/my-tasks.types';
import { useSocket } from '@/socket/socketContext';
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
import dayjs from 'dayjs';
+import { useTranslation } from 'react-i18next';
+import { Skeleton } from 'antd';
interface AddTaskInlineFormProps {
t: TFunction;
@@ -25,35 +27,41 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
const [isDueDateFieldShowing, setIsDueDateFieldShowing] = useState(false);
const [isProjectFieldShowing, setIsProjectFieldShowing] = useState(false);
const [form] = Form.useForm();
+ const { ready } = useTranslation('home');
+
+ const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
+ const { data: projectListData, isFetching: projectListFetching } = useGetProjectsByTeamQuery();
+ const { refetch } = useGetMyTasksQuery(homeTasksConfig);
const currentSession = useAuthService().getCurrentSession();
const { socket } = useSocket();
- const { data: projectListData, isFetching: projectListFetching } = useGetProjectsByTeamQuery();
- const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
- const { refetch } = useGetMyTasksQuery(homeTasksConfig);
-
const taskInputRef = useRef(null);
+ // Don't render until i18n is ready
+ if (!ready) {
+ return ;
+ }
+
const dueDateOptions = [
{
value: 'Today',
- label: t('home:tasks.today'),
+ label: t('tasks.today'),
},
{
value: 'Tomorrow',
- label: t('home:tasks.tomorrow'),
+ label: t('tasks.tomorrow'),
},
{
value: 'Next Week',
- label: t('home:tasks.nextWeek'),
+ label: t('tasks.nextWeek'),
},
{
value: 'Next Month',
- label: t('home:tasks.nextMonth'),
+ label: t('tasks.nextMonth'),
},
{
value: 'No Due Date',
- label: t('home:tasks.noDueDate'),
+ label: t('tasks.noDueDate'),
},
];
@@ -169,14 +177,14 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
rules={[
{
required: true,
- message: t('home:tasks.taskRequired'),
+ message: t('tasks.taskRequired'),
},
]}
>
{
const inputValue = e.currentTarget.value;
@@ -200,7 +208,7 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
- {t('home:tasks.pressTabToSelectDueDateAndProject')}
+ {t('tasks.pressTabToSelectDueDateAndProject')}
}
type="info"
@@ -245,7 +253,7 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
rules={[
{
required: true,
- message: t('home:tasks.projectRequired'),
+ message: t('tasks.projectRequired'),
},
]}
>
diff --git a/worklenz-frontend/src/pages/home/task-list/calendar-view.tsx b/worklenz-frontend/src/pages/home/task-list/calendar-view.tsx
index c0bfac8f..7a43c106 100644
--- a/worklenz-frontend/src/pages/home/task-list/calendar-view.tsx
+++ b/worklenz-frontend/src/pages/home/task-list/calendar-view.tsx
@@ -1,5 +1,5 @@
import HomeCalendar from '../../../components/calendars/homeCalendar/HomeCalendar';
-import { Tag, Typography } from 'antd';
+import { Tag, Typography, Skeleton } from 'antd';
import { ClockCircleOutlined } from '@ant-design/icons';
import { useAppSelector } from '@/hooks/useAppSelector';
import AddTaskInlineForm from './add-task-inline-form';
@@ -10,7 +10,12 @@ import dayjs from 'dayjs';
const CalendarView = () => {
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
- const { t } = useTranslation('home');
+ const { t, ready } = useTranslation('home');
+
+ // Don't render until i18n is ready
+ if (!ready) {
+ return ;
+ }
useEffect(() => {
if (!homeTasksConfig.selected_date) {
@@ -36,7 +41,7 @@ const CalendarView = () => {
}}
>
- {t('home:tasks.dueOn')} {homeTasksConfig.selected_date?.format('MMM DD, YYYY')}
+ {t('tasks.dueOn')} {homeTasksConfig.selected_date?.format('MMM DD, YYYY')}
diff --git a/worklenz-frontend/src/pages/home/task-list/tasks-list.tsx b/worklenz-frontend/src/pages/home/task-list/tasks-list.tsx
index 01e7706c..1c0bc08d 100644
--- a/worklenz-frontend/src/pages/home/task-list/tasks-list.tsx
+++ b/worklenz-frontend/src/pages/home/task-list/tasks-list.tsx
@@ -61,18 +61,23 @@ const TasksList: React.FC = React.memo(() => {
refetchOnFocus: false,
});
- const { t } = useTranslation('home');
+ const { t, ready } = useTranslation('home');
const { model } = useAppSelector(state => state.homePageReducer);
+ // Don't render until i18n is ready
+ if (!ready) {
+ return ;
+ }
+
const taskModes = useMemo(
() => [
{
value: 0,
- label: t('home:tasks.assignedToMe'),
+ label: t('tasks.assignedToMe'),
},
{
value: 1,
- label: t('home:tasks.assignedByMe'),
+ label: t('tasks.assignedByMe'),
},
],
[t]
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 f8715808..01675b62 100644
--- a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx
+++ b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx
@@ -10,6 +10,7 @@ import Tooltip from 'antd/es/tooltip';
import Typography from 'antd/es/typography';
import Button from 'antd/es/button';
import Alert from 'antd/es/alert';
+import Skeleton from 'antd/es/skeleton';
import EmptyListPlaceholder from '@components/EmptyListPlaceholder';
import { IMyTask } from '@/types/home/my-tasks.types';
@@ -24,7 +25,7 @@ import { useCreatePersonalTaskMutation } from '@/api/home-page/home-page.api.ser
const TodoList = () => {
const [isAlertShowing, setIsAlertShowing] = useState(false);
const [form] = Form.useForm();
- const { t } = useTranslation('home');
+ const { t, ready } = useTranslation('home');
const [createPersonalTask, { isLoading: isCreatingPersonalTask }] =
useCreatePersonalTaskMutation();
@@ -35,6 +36,11 @@ const TodoList = () => {
// ref for todo input field
const todoInputRef = useRef(null);
+ // Don't render until i18n is ready
+ if (!ready) {
+ return ;
+ }
+
// function to handle todo submit
const handleTodoSubmit = async (values: any) => {
if (!values.name || values.name.trim() === '') return;
@@ -43,6 +49,7 @@ const TodoList = () => {
done: false,
is_task: false,
color_code: '#000',
+ manual_progress: false,
};
const res = await createPersonalTask(newTodo);
@@ -69,7 +76,7 @@ const TodoList = () => {
width: 32,
render: (record: IMyTask) => (
-
+