feat(i18n): enhance internationalization support and optimize task updates

- Introduced I18nReadyCheck component to ensure translations are loaded before rendering the app.
- Updated various components to utilize the new i18n structure, improving translation management.
- Implemented optimistic UI updates for task properties during drag-and-drop operations, enhancing responsiveness.
- Added checks to prevent stale updates from socket events, ensuring the UI reflects the most recent task states.
- Enhanced error handling and logging for i18n initialization and loading processes.
This commit is contained in:
chamikaJ
2025-07-15 17:19:30 +05:30
parent 737f7cada2
commit 7eb55b315a
9 changed files with 192 additions and 47 deletions

View File

@@ -1,7 +1,8 @@
// Core dependencies // 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 { RouterProvider } from 'react-router-dom';
import i18next from 'i18next'; import i18next from 'i18next';
import { useTranslation } from 'react-i18next';
// Components // Components
import ThemeWrapper from './features/theme/ThemeWrapper'; import ThemeWrapper from './features/theme/ThemeWrapper';
@@ -27,6 +28,24 @@ import { CSSPerformanceMonitor, LayoutStabilizer, CriticalCSSManager } from './u
// Service Worker // Service Worker
import { registerSW } from './utils/serviceWorkerRegistration'; 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 <SuspenseFallback />;
}
return <>{children}</>;
};
/** /**
* Main App Component - Performance Optimized * Main App Component - Performance Optimized
* *
@@ -200,18 +219,15 @@ const App: React.FC = memo(() => {
}, []); }, []);
return ( return (
<Suspense fallback={<SuspenseFallback />}> <I18nReadyCheck>
<ThemeWrapper> <ModuleErrorBoundary>
<ModuleErrorBoundary> <ThemeWrapper>
<RouterProvider <Suspense fallback={<SuspenseFallback />}>
router={router} <RouterProvider router={router} />
future={{ </Suspense>
v7_startTransition: true, </ThemeWrapper>
}} </ModuleErrorBoundary>
/> </I18nReadyCheck>
</ModuleErrorBoundary>
</ThemeWrapper>
</Suspense>
); );
}); });

View File

@@ -250,6 +250,30 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
newPosition: insertIndex, 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 // reorderTasksInGroup handles both same-group and cross-group moves
// No need for separate moveTaskBetweenGroups call // No need for separate moveTaskBetweenGroups call
dispatch( dispatch(

View File

@@ -657,9 +657,15 @@ const taskManagementSlice = createSlice({
const group = state.groups.find(g => g.id === sourceGroupId); const group = state.groups.find(g => g.id === sourceGroupId);
if (group) { if (group) {
const newTasks = Array.from(group.taskIds); const newTasks = Array.from(group.taskIds);
const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1); const sourceIndex = newTasks.indexOf(sourceTaskId);
newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed); const destIndex = newTasks.indexOf(destinationTaskId);
group.taskIds = newTasks;
// 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 // Update order for affected tasks using the appropriate sort field
const sortField = getSortOrderField(state.grouping?.id); const sortField = getSortOrderField(state.grouping?.id);
@@ -681,13 +687,20 @@ const taskManagementSlice = createSlice({
// Add to destination group at the correct position relative to destinationTask // Add to destination group at the correct position relative to destinationTask
const destinationIndex = destinationGroup.taskIds.indexOf(destinationTaskId); const destinationIndex = destinationGroup.taskIds.indexOf(destinationTaskId);
if (destinationIndex !== -1) { 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 { } 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. // Note: Task's grouping field (priority, phase, status) will also be updated
// This will be handled by the socket event handler after backend confirmation. // 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 // Update order for affected tasks in both groups using the appropriate sort field
const sortField = getSortOrderField(state.grouping?.id); const sortField = getSortOrderField(state.grouping?.id);

View File

@@ -217,6 +217,17 @@ export const useTaskSocketHandlers = () => {
const currentGrouping = state.taskManagement.grouping; const currentGrouping = state.taskManagement.grouping;
if (currentTask) { 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 // Determine the new status value based on status category
let newStatusValue: 'todo' | 'doing' | 'done' = 'todo'; let newStatusValue: 'todo' | 'doing' | 'done' = 'todo';
if (response.statusCategory) { if (response.statusCategory) {
@@ -393,6 +404,17 @@ export const useTaskSocketHandlers = () => {
const currentGrouping = state.taskManagement.grouping; const currentGrouping = state.taskManagement.grouping;
if (currentTask) { 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 // Get priority list to map priority_id to priority name
const priorityList = state.priorityReducer?.priorities || []; const priorityList = state.priorityReducer?.priorities || [];
let newPriorityValue: 'critical' | 'high' | 'medium' | 'low' = 'medium'; let newPriorityValue: 'critical' | 'high' | 'medium' | 'low' = 'medium';
@@ -549,6 +571,17 @@ export const useTaskSocketHandlers = () => {
const currentTask = state.taskManagement.entities[taskId]; const currentTask = state.taskManagement.entities[taskId];
if (currentTask) { 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 // Get phase list to map phase_id to phase name
const phaseList = state.phaseReducer?.phaseList || []; const phaseList = state.phaseReducer?.phaseList || [];
let newPhaseValue = ''; let newPhaseValue = '';
@@ -1007,6 +1040,18 @@ export const useTaskSocketHandlers = () => {
data.forEach((taskData: any) => { data.forEach((taskData: any) => {
const currentTask = state.taskManagement.entities[taskData.id]; const currentTask = state.taskManagement.entities[taskData.id];
if (currentTask) { 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 = { let updatedTask: Task = {
...currentTask, ...currentTask,
order: taskData.sort_order || taskData.current_sort_order || currentTask.order, order: taskData.sort_order || taskData.current_sort_order || currentTask.order,

View File

@@ -24,11 +24,33 @@ i18n
backend: { backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json', loadPath: '/locales/{{lng}}/{{ns}}.json',
// Ensure translations are loaded synchronously
crossDomain: false,
withCredentials: false,
}, },
react: { react: {
useSuspense: false, 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; export default i18n;

View File

@@ -14,6 +14,8 @@ import { IMyTask } from '@/types/home/my-tasks.types';
import { useSocket } from '@/socket/socketContext'; import { useSocket } from '@/socket/socketContext';
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response'; import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useTranslation } from 'react-i18next';
import { Skeleton } from 'antd';
interface AddTaskInlineFormProps { interface AddTaskInlineFormProps {
t: TFunction; t: TFunction;
@@ -25,35 +27,41 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
const [isDueDateFieldShowing, setIsDueDateFieldShowing] = useState(false); const [isDueDateFieldShowing, setIsDueDateFieldShowing] = useState(false);
const [isProjectFieldShowing, setIsProjectFieldShowing] = useState(false); const [isProjectFieldShowing, setIsProjectFieldShowing] = useState(false);
const [form] = Form.useForm(); 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 currentSession = useAuthService().getCurrentSession();
const { socket } = useSocket(); const { socket } = useSocket();
const { data: projectListData, isFetching: projectListFetching } = useGetProjectsByTeamQuery();
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
const { refetch } = useGetMyTasksQuery(homeTasksConfig);
const taskInputRef = useRef<InputRef | null>(null); const taskInputRef = useRef<InputRef | null>(null);
// Don't render until i18n is ready
if (!ready) {
return <Skeleton active />;
}
const dueDateOptions = [ const dueDateOptions = [
{ {
value: 'Today', value: 'Today',
label: t('home:tasks.today'), label: t('tasks.today'),
}, },
{ {
value: 'Tomorrow', value: 'Tomorrow',
label: t('home:tasks.tomorrow'), label: t('tasks.tomorrow'),
}, },
{ {
value: 'Next Week', value: 'Next Week',
label: t('home:tasks.nextWeek'), label: t('tasks.nextWeek'),
}, },
{ {
value: 'Next Month', value: 'Next Month',
label: t('home:tasks.nextMonth'), label: t('tasks.nextMonth'),
}, },
{ {
value: 'No Due Date', value: 'No Due Date',
label: t('home:tasks.noDueDate'), label: t('tasks.noDueDate'),
}, },
]; ];
@@ -169,14 +177,14 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
rules={[ rules={[
{ {
required: true, required: true,
message: t('home:tasks.taskRequired'), message: t('tasks.taskRequired'),
}, },
]} ]}
> >
<Flex vertical gap={4}> <Flex vertical gap={4}>
<Input <Input
ref={taskInputRef} ref={taskInputRef}
placeholder={t('home:tasks.addTask')} placeholder={t('tasks.addTask')}
style={{ width: '100%' }} style={{ width: '100%' }}
onChange={e => { onChange={e => {
const inputValue = e.currentTarget.value; const inputValue = e.currentTarget.value;
@@ -200,7 +208,7 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
<Alert <Alert
message={ message={
<Typography.Text style={{ fontSize: 11 }}> <Typography.Text style={{ fontSize: 11 }}>
{t('home:tasks.pressTabToSelectDueDateAndProject')} {t('tasks.pressTabToSelectDueDateAndProject')}
</Typography.Text> </Typography.Text>
} }
type="info" type="info"
@@ -245,7 +253,7 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
rules={[ rules={[
{ {
required: true, required: true,
message: t('home:tasks.projectRequired'), message: t('tasks.projectRequired'),
}, },
]} ]}
> >

View File

@@ -1,5 +1,5 @@
import HomeCalendar from '../../../components/calendars/homeCalendar/HomeCalendar'; 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 { ClockCircleOutlined } from '@ant-design/icons';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import AddTaskInlineForm from './add-task-inline-form'; import AddTaskInlineForm from './add-task-inline-form';
@@ -10,7 +10,12 @@ import dayjs from 'dayjs';
const CalendarView = () => { const CalendarView = () => {
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer); 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 <Skeleton active />;
}
useEffect(() => { useEffect(() => {
if (!homeTasksConfig.selected_date) { if (!homeTasksConfig.selected_date) {
@@ -36,7 +41,7 @@ const CalendarView = () => {
}} }}
> >
<Typography.Text> <Typography.Text>
{t('home:tasks.dueOn')} {homeTasksConfig.selected_date?.format('MMM DD, YYYY')} {t('tasks.dueOn')} {homeTasksConfig.selected_date?.format('MMM DD, YYYY')}
</Typography.Text> </Typography.Text>
</Tag> </Tag>

View File

@@ -61,18 +61,23 @@ const TasksList: React.FC = React.memo(() => {
refetchOnFocus: false, refetchOnFocus: false,
}); });
const { t } = useTranslation('home'); const { t, ready } = useTranslation('home');
const { model } = useAppSelector(state => state.homePageReducer); const { model } = useAppSelector(state => state.homePageReducer);
// Don't render until i18n is ready
if (!ready) {
return <Skeleton active />;
}
const taskModes = useMemo( const taskModes = useMemo(
() => [ () => [
{ {
value: 0, value: 0,
label: t('home:tasks.assignedToMe'), label: t('tasks.assignedToMe'),
}, },
{ {
value: 1, value: 1,
label: t('home:tasks.assignedByMe'), label: t('tasks.assignedByMe'),
}, },
], ],
[t] [t]

View File

@@ -10,6 +10,7 @@ import Tooltip from 'antd/es/tooltip';
import Typography from 'antd/es/typography'; import Typography from 'antd/es/typography';
import Button from 'antd/es/button'; import Button from 'antd/es/button';
import Alert from 'antd/es/alert'; import Alert from 'antd/es/alert';
import Skeleton from 'antd/es/skeleton';
import EmptyListPlaceholder from '@components/EmptyListPlaceholder'; import EmptyListPlaceholder from '@components/EmptyListPlaceholder';
import { IMyTask } from '@/types/home/my-tasks.types'; 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 TodoList = () => {
const [isAlertShowing, setIsAlertShowing] = useState(false); const [isAlertShowing, setIsAlertShowing] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const { t } = useTranslation('home'); const { t, ready } = useTranslation('home');
const [createPersonalTask, { isLoading: isCreatingPersonalTask }] = const [createPersonalTask, { isLoading: isCreatingPersonalTask }] =
useCreatePersonalTaskMutation(); useCreatePersonalTaskMutation();
@@ -35,6 +36,11 @@ const TodoList = () => {
// ref for todo input field // ref for todo input field
const todoInputRef = useRef<InputRef | null>(null); const todoInputRef = useRef<InputRef | null>(null);
// Don't render until i18n is ready
if (!ready) {
return <Skeleton active />;
}
// function to handle todo submit // function to handle todo submit
const handleTodoSubmit = async (values: any) => { const handleTodoSubmit = async (values: any) => {
if (!values.name || values.name.trim() === '') return; if (!values.name || values.name.trim() === '') return;
@@ -43,6 +49,7 @@ const TodoList = () => {
done: false, done: false,
is_task: false, is_task: false,
color_code: '#000', color_code: '#000',
manual_progress: false,
}; };
const res = await createPersonalTask(newTodo); const res = await createPersonalTask(newTodo);
@@ -69,7 +76,7 @@ const TodoList = () => {
width: 32, width: 32,
render: (record: IMyTask) => ( render: (record: IMyTask) => (
<ConfigProvider wave={{ disabled: true }}> <ConfigProvider wave={{ disabled: true }}>
<Tooltip title={t('home:todoList.markAsDone')}> <Tooltip title={t('todoList.markAsDone')}>
<Button <Button
type="text" type="text"
className="borderless-icon-btn" className="borderless-icon-btn"
@@ -100,11 +107,11 @@ const TodoList = () => {
<Card <Card
title={ title={
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}> <Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
{t('home:todoList.title')} ({data?.body.length}) {t('todoList.title')} ({data?.body.length})
</Typography.Title> </Typography.Title>
} }
extra={ extra={
<Tooltip title={t('home:todoList.refreshTasks')}> <Tooltip title={t('todoList.refreshTasks')}>
<Button shape="circle" icon={<SyncOutlined spin={isFetching} />} onClick={refetch} /> <Button shape="circle" icon={<SyncOutlined spin={isFetching} />} onClick={refetch} />
</Tooltip> </Tooltip>
} }
@@ -116,7 +123,7 @@ const TodoList = () => {
<Flex vertical> <Flex vertical>
<Input <Input
ref={todoInputRef} ref={todoInputRef}
placeholder={t('home:todoList.addTask')} placeholder={t('todoList.addTask')}
onChange={e => { onChange={e => {
const inputValue = e.currentTarget.value; const inputValue = e.currentTarget.value;
@@ -128,8 +135,8 @@ const TodoList = () => {
<Alert <Alert
message={ message={
<Typography.Text style={{ fontSize: 11 }}> <Typography.Text style={{ fontSize: 11 }}>
{t('home:todoList.pressEnter')} <strong>Enter</strong>{' '} {t('todoList.pressEnter')} <strong>Enter</strong>{' '}
{t('home:todoList.toCreate')} {t('todoList.toCreate')}
</Typography.Text> </Typography.Text>
} }
type="info" type="info"
@@ -148,7 +155,7 @@ const TodoList = () => {
{data?.body.length === 0 ? ( {data?.body.length === 0 ? (
<EmptyListPlaceholder <EmptyListPlaceholder
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp" imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
text={t('home:todoList.noTasks')} text={t('todoList.noTasks')}
/> />
) : ( ) : (
<Table <Table