diff --git a/src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.jsx b/src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.jsx
new file mode 100644
index 00000000000..f935e5433f6
--- /dev/null
+++ b/src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.jsx
@@ -0,0 +1,321 @@
+import { useRef, useState } from 'react';
+import PropTypes from 'prop-types';
+import axios from 'axios';
+import { ENDPOINTS } from '~/utils/URL';
+import {
+ allocationMinutes,
+ formatSplitMinutes,
+ readSplitDraft,
+ splitDraftKey,
+ splitValidation,
+} from './splitTimeEntryUtils';
+import styles from './SplitTimeEntryForm.module.css';
+
+const emptyRow = () => ({
+ id: crypto.randomUUID(),
+ taskId: '',
+ hours: '0',
+ minutes: '0',
+ notes: '',
+});
+
+async function requestReceipt(payload) {
+ try {
+ return await axios.post(ENDPOINTS.TIME_ENTRY_SPLIT(), payload);
+ } catch (error_) {
+ if (error_.response?.status !== 409) throw error_;
+ // Another tab may already have saved this server-issued timer session.
+ return axios.get(ENDPOINTS.TIME_ENTRY_SPLIT_RECEIPT(payload.batchId));
+ }
+}
+
+function submitLabel(busy, saved, locked, count) {
+ if (busy) return 'Saving…';
+ if (saved) return 'Finish timer';
+ if (locked) return 'Retry split save';
+ return `Submit ${count} time entries`;
+}
+
+export default function SplitTimeEntryForm({
+ personId,
+ batchId,
+ dateOfWork,
+ totalMinutes,
+ tasks,
+ loading,
+ connected,
+ darkMode,
+ onComplete,
+ onLockChange,
+}) {
+ const [draft, setDraft] = useState(() => {
+ const saved = readSplitDraft(personId);
+ if (saved && (saved.attempted || saved.payload.batchId === batchId)) return saved;
+ return {
+ payload: { personId, batchId, dateOfWork, totalMinutes },
+ rows: [emptyRow(), emptyRow()],
+ attempted: false,
+ saved: false,
+ };
+ });
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const inFlight = useRef(false);
+ const locked = draft.attempted;
+ const activeTasks = tasks.filter(task => task.isActive !== false && task.deleted !== true);
+ const allocated = draft.rows.reduce((sum, row) => sum + (allocationMinutes(row) || 0), 0);
+ const remaining = draft.payload.totalMinutes - allocated;
+ const validation = splitValidation(
+ draft.rows,
+ draft.payload.totalMinutes,
+ activeTasks.map(task => task._id),
+ );
+
+ const persist = next => {
+ localStorage.setItem(splitDraftKey(personId), JSON.stringify(next));
+ setDraft(next);
+ };
+ const updateDraft = next => {
+ try {
+ persist(next);
+ setError('');
+ } catch {
+ setDraft(next);
+ setError(
+ 'Your browser could not keep a recovery copy. Allow site storage before saving a split.',
+ );
+ }
+ };
+ const change = (id, field, value) => {
+ const next = {
+ ...draft,
+ rows: draft.rows.map(row => (row.id === id ? { ...row, [field]: value } : row)),
+ };
+ updateDraft(next);
+ };
+ const applyReceipt = (receipt, current) => ({
+ ...current,
+ saved: true,
+ receipt,
+ payload: {
+ ...current.payload,
+ dateOfWork: receipt.dateOfWork,
+ totalMinutes: receipt.totalMinutes,
+ },
+ rows: receipt.allocations.map((row, index) => ({
+ id: String(index),
+ taskId: String(row.taskId),
+ hours: String(Math.floor(row.minutes / 60)),
+ minutes: String(row.minutes % 60),
+ notes: row.notes,
+ })),
+ });
+
+ const submit = async () => {
+ if (inFlight.current || !connected || (!locked && (loading || validation))) return;
+ inFlight.current = true;
+ setBusy(true);
+ setError('');
+ let current = draft;
+ try {
+ if (!current.attempted) {
+ current = {
+ ...current,
+ attempted: true,
+ payload: {
+ ...current.payload,
+ allocations: current.rows.map(row => ({
+ taskId: row.taskId,
+ minutes: allocationMinutes(row),
+ notes: row.notes,
+ })),
+ },
+ };
+ // Keep the exact submitted body before sending; retries cannot change it.
+ persist(current);
+ onLockChange(true);
+ }
+ if (!current.saved) {
+ const response = await requestReceipt(current.payload);
+ current = applyReceipt(response.data, current);
+ persist(current);
+ }
+ const complete = await onComplete(current.receipt, current.payload);
+ if (!complete)
+ throw new Error(
+ 'Time is saved, but the timer stop is not confirmed. Choose Finish timer to retry without adding time.',
+ );
+ try {
+ localStorage.removeItem(splitDraftKey(personId));
+ } catch {
+ /* The server receipt still prevents duplicates. */
+ }
+ onLockChange(false);
+ } catch (error_) {
+ if (!current.saved && [400, 403, 404].includes(error_.response?.status)) {
+ current = { ...current, attempted: false };
+ try {
+ persist(current);
+ } catch {
+ setDraft(current);
+ }
+ onLockChange(false);
+ }
+ setError(
+ error_.response?.data?.error ||
+ error_.message ||
+ 'Could not confirm the save. Your allocations are kept; retry this split.',
+ );
+ } finally {
+ inFlight.current = false;
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+ Divide this session between your assigned tasks. Each task needs its own work description
+ and reference link.
+
+
+ Session date: {draft.payload.dateOfWork}
+
+
+
+
- Session total
+ - {formatSplitMinutes(draft.payload.totalMinutes)}
+
+
+
Allocated
+ {formatSplitMinutes(allocated)}
+
+
+
{remaining < 0 ? 'Over allocated' : 'Remaining'}
+ {formatSplitMinutes(remaining)}
+
+
+ {locked && (
+
+ )}
+
+ {loading && }
+ {!locked && validation && {validation}
}
+ {error && (
+
+ {error}
+
+ )}
+ {!connected && Reconnect the timer before saving or finishing this split.
}
+
+
+ );
+}
+
+SplitTimeEntryForm.propTypes = {
+ personId: PropTypes.string.isRequired,
+ batchId: PropTypes.string.isRequired,
+ dateOfWork: PropTypes.string.isRequired,
+ totalMinutes: PropTypes.number.isRequired,
+ tasks: PropTypes.arrayOf(
+ PropTypes.shape({ _id: PropTypes.string.isRequired, taskName: PropTypes.string.isRequired }),
+ ).isRequired,
+ loading: PropTypes.bool.isRequired,
+ connected: PropTypes.bool.isRequired,
+ darkMode: PropTypes.bool,
+ onComplete: PropTypes.func.isRequired,
+ onLockChange: PropTypes.func.isRequired,
+};
diff --git a/src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.module.css b/src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.module.css
new file mode 100644
index 00000000000..3d5da7738a0
--- /dev/null
+++ b/src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.module.css
@@ -0,0 +1,144 @@
+.split {
+ color: #243244;
+ min-width: 0;
+}
+
+.status {
+ display: block;
+ margin-bottom: 16px;
+}
+
+.totals {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ padding: 12px;
+ background: #eef3f7;
+ border-radius: 6px;
+}
+
+.totals dt {
+ font-size: 12px;
+}
+
+.totals dd {
+ font-size: 18px;
+ margin: 4px 0 0;
+ font-variant-numeric: tabular-nums;
+}
+
+.rows {
+ border: 0;
+ margin: 0;
+ padding: 0;
+ min-width: 0;
+}
+
+.legend {
+ font-size: 18px;
+}
+
+.row {
+ border: 1px solid #a5b1bd;
+ border-radius: 6px;
+ padding: 12px;
+ margin-bottom: 12px;
+}
+
+.row label {
+ display: block;
+ margin-top: 8px;
+}
+
+.row select,
+.row textarea,
+.row input {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ border: 1px solid #7a8999;
+ border-radius: 4px;
+ padding: 8px;
+ background: #fff;
+ color: #182432;
+}
+
+.time {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.split button {
+ border: 1px solid #506477;
+ border-radius: 4px;
+ background: #edf2f7;
+ color: #203448;
+ padding: 8px 12px;
+ margin-top: 10px;
+}
+
+.split .submit {
+ background: #075aaa;
+ color: #fff;
+ border-color: #075aaa;
+}
+
+
+
+.error {
+ background: #7b1830;
+ color: #fff;
+ padding: 10px;
+ margin-top: 10px;
+ border-radius: 4px;
+}
+
+.dark {
+ color: #fff;
+ background: #223d58;
+ border-radius: 6px;
+ padding: 12px;
+}
+
+.dark .totals {
+ background: #172b40;
+}
+
+.dark .row select,
+.dark .row textarea,
+.dark .row input {
+ background: #172b40;
+ color: #fff;
+ border-color: #8ba3ba;
+}
+
+.dark button {
+ background: #223d58;
+ color: #fff;
+ border-color: #8ba3ba;
+}
+
+.dark .submit {
+ background: #075aaa;
+}
+
+.split button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+@media (width <= 380px) {
+ .row {
+ padding: 8px;
+ }
+
+ .totals {
+ gap: 4px;
+ padding: 8px;
+ }
+
+ .totals dd {
+ font-size: 16px;
+ }
+}
diff --git a/src/components/Timelog/TimeEntryForm/TimeEntryForm.jsx b/src/components/Timelog/TimeEntryForm/TimeEntryForm.jsx
index cfadb2d52b2..d87ffaa56ef 100644
--- a/src/components/Timelog/TimeEntryForm/TimeEntryForm.jsx
+++ b/src/components/Timelog/TimeEntryForm/TimeEntryForm.jsx
@@ -28,9 +28,12 @@ import ReactTooltip from 'react-tooltip';
import axios from 'axios';
import { getUserProfile } from '~/actions/userProfile';
import hasPermission from '~/utils/permissions';
+import { fetchTeamMembersTask } from '~/actions/task';
import { boxStyle, boxStyleDark } from '~/styles';
import { postTimeEntry, editTimeEntry, getTimeEntriesForWeek } from '../../../actions/timeEntries';
import AboutModal from './AboutModal';
+import SplitTimeEntryForm from './SplitTimeEntryForm';
+import { readSplitDraft } from './splitTimeEntryUtils';
import TangibleInfoModal from './TangibleInfoModal';
import ReminderModal from './ReminderModal';
import TimeLogConfirmationModal from './TimeLogConfirmationModal';
@@ -67,7 +70,93 @@ const customImageUploadHandler = () =>
* @returns
*/
-function TimeEntryForm(props) {
+function renderTimeEntryHeading({ edit, isTangible, darkMode, viewingUser, onAbout, loading }) {
+ return (
+ <>
+
+ {edit ? 'Edit ' : 'Add '}
+ {isTangible ? (
+ Tangible
+ ) : (
+ Intangible
+ )}
+ Time Entry
+ {viewingUser.userId ? ` for ${viewingUser.firstName} ${viewingUser.lastName} ` : ' '}
+
+ {loading && Loading Data...}
+
+
+ Click this icon to learn about this time entry form
+
+ >
+ );
+}
+
+function initialProjectSelection(edit, projectId, wbsId, taskId) {
+ if (!edit) return 'defaultProject';
+ return projectId + (wbsId ? `/${wbsId}` : '') + (taskId ? `/${taskId}` : '');
+}
+
+function entryUserId(from, viewingUser, authUser, data) {
+ return from === 'Timer' ? viewingUser.userId ?? authUser.userid : data.personId;
+}
+
+function allowsTimeChange(from, canEditTimeEntryTime, isSameDayAuthUserEdit) {
+ return from !== 'Timer' && (from === 'TimeLog' || canEditTimeEntryTime || isSameDayAuthUserEdit);
+}
+
+function timeEntryEditorOptions(darkMode) {
+ return {
+ license_key: 'gpl',
+ menubar: false,
+ placeholder: 'Description (10-word minimum) and reference link',
+ plugins: 'advlist autolink autoresize lists link charmap table help wordcount',
+ toolbar:
+ // eslint-disable-next-line no-multi-str
+ 'bold italic underline link removeformat | bullist numlist outdent indent |\
+ styleselect fontsizeselect | table| strikethrough forecolor backcolor |\
+ subscript superscript charmap | help',
+ branding: false,
+ toolbar_mode: 'sliding',
+ min_height: 250,
+ max_height: 300,
+ autoresize_bottom_margin: 1,
+ content_style:
+ 'body { cursor: text !important; } .mce-content-body[data-mce-placeholder]:focus::before {content: "";}',
+ images_upload_handler: customImageUploadHandler,
+ skin: darkMode ? 'oxide-dark' : 'oxide',
+ content_css: darkMode ? 'dark' : 'default',
+ };
+}
+
+function timeEntryTheme(darkMode) {
+ if (darkMode)
+ return {
+ fontColor: 'text-light',
+ headerBg: 'bg-space-cadet',
+ bodyBg: 'bg-yinmn-blue',
+ modalClass: 'text-light dark-mode',
+ modalStyle: boxStyleDark,
+ buttonStyle: boxStyleDark,
+ };
+ return {
+ fontColor: '',
+ headerBg: '',
+ bodyBg: '',
+ modalClass: '',
+ modalStyle: {},
+ buttonStyle: boxStyle,
+ };
+}
+
+export function TimeEntryForm(props) {
/* ---------------- variables -------------- */
// props from parent
const {
@@ -111,28 +200,9 @@ function TimeEntryForm(props) {
...data,
};
- const TINY_MCE_INIT_OPTIONS = {
- license_key: 'gpl',
- menubar: false,
- placeholder: 'Description (10-word minimum) and reference link',
- plugins: 'advlist autolink autoresize lists link charmap table help wordcount',
- toolbar:
- // eslint-disable-next-line no-multi-str
- 'bold italic underline link removeformat | bullist numlist outdent indent |\
- styleselect fontsizeselect | table| strikethrough forecolor backcolor |\
- subscript superscript charmap | help',
- branding: false,
- toolbar_mode: 'sliding',
- min_height: 250,
- max_height: 300,
- autoresize_bottom_margin: 1,
- content_style: 'body { cursor: text !important; } .mce-content-body[data-mce-placeholder]:focus::before {content: "";}',
- images_upload_handler: customImageUploadHandler,
- skin: darkMode ? 'oxide-dark' : 'oxide',
- content_css: darkMode ? 'dark' : 'default',
- };
+ const TINY_MCE_INIT_OPTIONS = timeEntryEditorOptions(darkMode);
- const timeEntryUserId = from === 'Timer' ? viewingUser.userId ?? authUser.userid : data.personId;
+ const timeEntryUserId = entryUserId(from, viewingUser, authUser, data);
const {
dateOfWork: initialDateOfWork,
@@ -145,11 +215,7 @@ function TimeEntryForm(props) {
isTangible: initialIsTangible,
} = initialFormValues;
- const timeEntryInitialProjectOrTaskId = edit
- ? initialProjectId +
- (initialwbsId ? `/${initialwbsId}` : '') +
- (initialTaskId ? `/${initialTaskId}` : '')
- : 'defaultProject';
+ const timeEntryInitialProjectOrTaskId = initialProjectSelection(edit, initialProjectId, initialwbsId, initialTaskId);
const initialReminder = {
openModal: false,
@@ -175,6 +241,36 @@ function TimeEntryForm(props) {
const [isTimelogConfirmationModalVisible, setTimelogConfirmationModalVisible] = useState(false);
const [projectsAndTasksOptions, setProjectsAndTasksOptions] = useState([]);
const [submitting, setSubmitting] = useState(false);
+ const splitAvailable = from === 'Timer' && !edit && formValues.personId === authUser.userid;
+ const hasPendingSplit = () => splitAvailable && Boolean(readSplitDraft(authUser.userid)?.attempted);
+ const [splitMode, setSplitMode] = useState(hasPendingSplit);
+ const [splitLocked, setSplitLocked] = useState(hasPendingSplit);
+ const toggleForm = () => {
+ if (!splitLocked) toggle();
+ };
+ const finishSplit = async (receipt, payload) => {
+ // A recovered save may belong to a timer that was reset in another tab.
+ if (props.timerSessionId === payload.batchId) {
+ const stopped = await sendStop(payload.batchId);
+ if (!stopped) return false;
+ if (onTimeSubmitted) {
+ onTimeSubmitted({
+ hours: Math.floor(receipt.totalMinutes / 60),
+ minutes: receipt.totalMinutes % 60,
+ sessionId,
+ });
+ }
+ } else {
+ toast.info('The earlier split is saved. Your current timer was left running.');
+ }
+ await Promise.allSettled([
+ Promise.resolve().then(() => props.getUserProfile(payload.personId)),
+ Promise.resolve().then(() => props.getTimeEntriesForWeek(payload.personId, 0)),
+ Promise.resolve().then(() => dispatch(fetchTeamMembersTask(payload.personId))),
+ ]);
+ toggle();
+ return true;
+ };
const isForAuthUser = timeEntryUserId === authUser.userid;
const isSameDayTimeEntry =
@@ -191,8 +287,7 @@ function TimeEntryForm(props) {
const canPutUserProfileImportantInfo = props.hasPermission(permissions.putUserProfileImportantInfo);
// Administrator/Owner can add time entries for any dates, and other roles can only edit their own time entry in the same day.
- const canChangeTime =
- from !== 'Timer' && (from === 'TimeLog' || canEditTimeEntryTime || isSameDayAuthUserEdit);
+ const canChangeTime = allowsTimeChange(from, canEditTimeEntryTime, isSameDayAuthUserEdit);
/* ---------------- methods -------------- */
const toggleRemainder = () =>
@@ -682,42 +777,19 @@ function TimeEntryForm(props) {
setFormValues(prev => ({ ...prev, ...data }));
}, [data]);
- const fontColor = darkMode ? 'text-light' : '';
- const headerBg = darkMode ? 'bg-space-cadet' : '';
- const bodyBg = darkMode ? 'bg-yinmn-blue' : '';
+ const { fontColor, headerBg, bodyBg, modalClass, modalStyle, buttonStyle } = timeEntryTheme(darkMode);
return (
<>
-
-
- {edit ? 'Edit ' : 'Add '}
- {formValues.isTangible ? (
- Tangible
- ) : (
- Intangible
- )}
- Time Entry
- {viewingUser.userId ? ` for ${viewingUser.firstName} ${viewingUser.lastName} ` : ' '}
-
- {!isAsyncDataLoaded && Loading Data...}
-
-
- Click this icon to learn about this time entry form
-
+
+ {renderTimeEntryHeading({ edit, isTangible: formValues.isTangible, darkMode, viewingUser, onAbout: aboutModalToggle, loading: !isAsyncDataLoaded })}
* All the fields are required
-
({ default: { post: vi.fn(), get: vi.fn() } }));
+const personId = 'aaaaaaaaaaaaaaaaaaaaaaaa';
+const tasks = [
+ { _id: 'bbbbbbbbbbbbbbbbbbbbbbbb', taskName: 'Terms checkbox' },
+ { _id: 'cccccccccccccccccccccccc', taskName: 'Hours hotfix' },
+];
+const notes =
+ 'Implemented and tested the assigned task with isolated fixtures and documented the result https://example.org/pr';
+let complete;
+let lock;
+const receipt = () => ({
+ batchId: 'server-session-1234567890',
+ totalMinutes: 300,
+ dateOfWork: '2026-11-05',
+ entryIds: ['one', 'two'],
+ allocations: tasks.map((task, index) => ({
+ taskId: task._id,
+ minutes: index ? 120 : 180,
+ notes,
+ })),
+});
+const mount = extra =>
+ render(
+ ,
+ );
+const fill = () => {
+ fireEvent.change(screen.getByLabelText('Task 1'), { target: { value: tasks[0]._id } });
+ fireEvent.change(screen.getByLabelText('Task 2'), { target: { value: tasks[1]._id } });
+ const hours = screen.getAllByLabelText('Hours');
+ fireEvent.change(hours[0], { target: { value: '3' } });
+ fireEvent.change(hours[1], { target: { value: '2' } });
+ screen
+ .getAllByLabelText('Work description and reference link')
+ .forEach(input => fireEvent.change(input, { target: { value: notes } }));
+};
+beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ complete = vi.fn().mockResolvedValue(true);
+ lock = vi.fn();
+ axios.post.mockResolvedValue({ data: receipt() });
+});
+
+describe('Split timer allocations', () => {
+ it('disables submission until all rows are complete and time matches exactly', () => {
+ mount();
+ expect(screen.getByRole('button', { name: 'Submit 2 time entries' })).toBeDisabled();
+ fill();
+ expect(screen.getByRole('button', { name: 'Submit 2 time entries' })).toBeEnabled();
+ fireEvent.change(screen.getAllByLabelText('Hours')[0], { target: { value: '4' } });
+ expect(screen.getByText('Over allocated')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Submit 2 time entries' })).toBeDisabled();
+ });
+ it('posts one batch with separate task descriptions and total minutes', async () => {
+ mount();
+ fill();
+ fireEvent.click(screen.getByRole('button', { name: 'Submit 2 time entries' }));
+ await waitFor(() => expect(complete).toHaveBeenCalledTimes(1));
+ expect(axios.post).toHaveBeenCalledTimes(1);
+ expect(axios.post).toHaveBeenCalledWith(
+ ENDPOINTS.TIME_ENTRY_SPLIT(),
+ expect.objectContaining({
+ personId,
+ totalMinutes: 300,
+ batchId: 'server-session-1234567890',
+ allocations: receipt().allocations,
+ }),
+ );
+ expect(localStorage.getItem(splitDraftKey(personId))).toBeNull();
+ });
+ it('keeps and locks the submitted draft on an uncertain failure, then retries the exact payload', async () => {
+ axios.post.mockRejectedValueOnce(new Error('Lost response'));
+ mount();
+ fill();
+ fireEvent.click(screen.getByRole('button', { name: 'Submit 2 time entries' }));
+ const retry = await screen.findByRole('button', { name: 'Retry split save' });
+ expect(screen.getByLabelText('Task 1')).toBeDisabled();
+ expect(JSON.parse(localStorage.getItem(splitDraftKey(personId))).attempted).toBe(true);
+ fireEvent.click(retry);
+ await waitFor(() => expect(complete).toHaveBeenCalled());
+ expect(axios.post.mock.calls[0][1]).toEqual(axios.post.mock.calls[1][1]);
+ });
+ it('recovers an uncertain draft after unmount without losing its original session key', async () => {
+ axios.post.mockRejectedValueOnce(new Error('offline'));
+ const view = mount();
+ fill();
+ fireEvent.click(screen.getByRole('button', { name: 'Submit 2 time entries' }));
+ await screen.findByRole('button', { name: 'Retry split save' });
+ view.unmount();
+ mount({ batchId: 'newer-timer-session' });
+ expect(screen.getByLabelText('Task 1')).toHaveValue(tasks[0]._id);
+ fireEvent.click(screen.getByRole('button', { name: 'Retry split save' }));
+ await waitFor(() => expect(complete).toHaveBeenCalled());
+ expect(axios.post.mock.calls[1][1].batchId).toBe('server-session-1234567890');
+ });
+ it('does not repost entries when timer-stop confirmation fails', async () => {
+ complete.mockResolvedValueOnce(false);
+ mount();
+ fill();
+ fireEvent.click(screen.getByRole('button', { name: 'Submit 2 time entries' }));
+ fireEvent.click(await screen.findByRole('button', { name: 'Finish timer' }));
+ await waitFor(() => expect(complete).toHaveBeenCalledTimes(2));
+ expect(axios.post).toHaveBeenCalledTimes(1);
+ });
+ it('refreshes the original receipt if another tab already saved this session', async () => {
+ axios.post.mockRejectedValueOnce({ response: { status: 409 } });
+ axios.get.mockResolvedValueOnce({ data: receipt() });
+ mount();
+ fill();
+ fireEvent.click(screen.getByRole('button', { name: 'Submit 2 time entries' }));
+ await waitFor(() => expect(complete).toHaveBeenCalled());
+ expect(axios.get).toHaveBeenCalledWith(
+ ENDPOINTS.TIME_ENTRY_SPLIT_RECEIPT('server-session-1234567890'),
+ );
+ expect(axios.post).toHaveBeenCalledTimes(1);
+ });
+ it('unlocks a rejected request without clearing allocations', async () => {
+ axios.post.mockRejectedValueOnce({
+ response: { status: 403, data: { error: 'Task assignment changed' } },
+ });
+ mount();
+ fill();
+ fireEvent.click(screen.getByRole('button', { name: 'Submit 2 time entries' }));
+ await screen.findByText('Task assignment changed');
+ expect(screen.getByLabelText('Task 1')).toBeEnabled();
+ expect(screen.getAllByLabelText('Hours')[0]).toHaveValue(3);
+ expect(complete).not.toHaveBeenCalled();
+ });
+ it('blocks rapid double-clicks while the request is in flight', async () => {
+ let resolve;
+ axios.post.mockImplementationOnce(
+ () =>
+ new Promise(done => {
+ resolve = done;
+ }),
+ );
+ mount();
+ fill();
+ const button = screen.getByRole('button', { name: 'Submit 2 time entries' });
+ fireEvent.click(button);
+ fireEvent.click(button);
+ expect(axios.post).toHaveBeenCalledTimes(1);
+ resolve({ data: receipt() });
+ await waitFor(() => expect(complete).toHaveBeenCalled());
+ });
+ it.each([{ loading: true }, { connected: false }])(
+ 'does not submit while unavailable: %j',
+ extra => {
+ mount(extra);
+ expect(screen.getByRole('button', { name: 'Submit 2 time entries' })).toBeDisabled();
+ expect(axios.post).not.toHaveBeenCalled();
+ },
+ );
+ it('blocks invalid minutes, missing references and duplicate tasks', () => {
+ mount();
+ fill();
+ fireEvent.change(screen.getAllByLabelText('Minutes')[0], { target: { value: '60' } });
+ expect(screen.getByRole('button', { name: 'Submit 2 time entries' })).toBeDisabled();
+ fireEvent.change(screen.getAllByLabelText('Minutes')[0], { target: { value: '0' } });
+ fireEvent.change(screen.getByLabelText('Task 2'), { target: { value: tasks[0]._id } });
+ expect(screen.getByText('Each task may appear only once.')).toBeInTheDocument();
+ fireEvent.change(screen.getByLabelText('Task 2'), { target: { value: tasks[1]._id } });
+ fireEvent.change(screen.getAllByLabelText('Work description and reference link')[0], {
+ target: { value: 'Insufficient notes' },
+ });
+ expect(screen.getByRole('button', { name: 'Submit 2 time entries' })).toBeDisabled();
+ });
+ it('fails safely without sending a request when recovery storage is unavailable', () => {
+ mount();
+ fill();
+ vi.spyOn(localStorage, 'setItem').mockImplementationOnce(() => {
+ throw new Error('Storage unavailable');
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Submit 2 time entries' }));
+ expect(axios.post).not.toHaveBeenCalled();
+ expect(complete).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/Timelog/TimeEntryForm/__tests__/TimeEntryForm.split.test.jsx b/src/components/Timelog/TimeEntryForm/__tests__/TimeEntryForm.split.test.jsx
new file mode 100644
index 00000000000..9f7e7ea1582
--- /dev/null
+++ b/src/components/Timelog/TimeEntryForm/__tests__/TimeEntryForm.split.test.jsx
@@ -0,0 +1,148 @@
+import { Provider } from 'react-redux';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import configureMockStore from 'redux-mock-store';
+import axios from 'axios';
+import { TimeEntryForm } from '../TimeEntryForm';
+import { ENDPOINTS } from '../../../../utils/URL';
+import { splitDraftKey } from '../splitTimeEntryUtils';
+
+vi.mock('axios', () => ({ default: { get: vi.fn(), post: vi.fn() } }));
+vi.mock('~/actions/task', () => ({ fetchTeamMembersTask: id => ({ type: 'REFRESH_TASKS', id }) }));
+const personId = 'aaaaaaaaaaaaaaaaaaaaaaaa';
+const batchId = 'server-session-1234567890';
+const notes =
+ 'Implemented and tested the assigned task with isolated fixtures and documented the result https://example.org/pr';
+const tasks = ['bbbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccccccc'].map((_id, index) => ({
+ _id,
+ taskName: `Task ${index + 1} fixture`,
+ isActive: true,
+ projectId: 'dddddddddddddddddddddddd',
+ projectName: 'Fixture project',
+ wbsId: 'eeeeeeeeeeeeeeeeeeeeeeee',
+ wbsName: 'Fixture WBS',
+}));
+let props;
+let store;
+const mount = extra =>
+ render(
+
+
+ ,
+ );
+const fill = async () => {
+ fireEvent.click(screen.getByLabelText('Worked on multiple tasks?'));
+ await waitFor(() => expect(screen.getByLabelText('Task 1')).toBeEnabled());
+ fireEvent.change(screen.getByLabelText('Task 1'), { target: { value: tasks[0]._id } });
+ fireEvent.change(screen.getByLabelText('Task 2'), { target: { value: tasks[1]._id } });
+ fireEvent.change(screen.getAllByLabelText('Hours')[0], { target: { value: '3' } });
+ fireEvent.change(screen.getAllByLabelText('Hours')[1], { target: { value: '2' } });
+ screen
+ .getAllByLabelText('Work description and reference link')
+ .forEach(input => fireEvent.change(input, { target: { value: notes } }));
+};
+beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ sessionStorage.clear();
+ store = configureMockStore()({ auth: { user: { userid: personId } } });
+ props = {
+ from: 'Timer',
+ edit: false,
+ isOpen: true,
+ data: { hours: 5, minutes: 0, notes: '' },
+ authUser: { userid: personId },
+ darkMode: false,
+ timerSessionId: batchId,
+ timerConnected: true,
+ sessionId: 'client-session',
+ toggle: vi.fn(),
+ sendStop: vi.fn().mockResolvedValue(true),
+ hasPermission: () => false,
+ getUserProfile: vi.fn(),
+ getTimeEntriesForWeek: vi.fn(),
+ onTimeSubmitted: vi.fn(),
+ };
+ axios.get.mockImplementation(async url => ({
+ data: url.includes('/tasks/user/')
+ ? tasks
+ : url.includes('/projects/user/')
+ ? []
+ : { isFirstTimelog: false, startDate: '2026-01-01', timeEntryEditHistory: [] },
+ }));
+ axios.post.mockImplementation(async (_url, body) => ({
+ data: { ...body, entryIds: ['one', 'two'] },
+ }));
+});
+it('starts in the original single-entry workflow and allows switching back before submission', async () => {
+ mount();
+ expect(screen.getByLabelText('Worked on multiple tasks?')).not.toBeChecked();
+ expect(screen.getByLabelText('Project/Task')).toBeInTheDocument();
+ await fill();
+ expect(screen.queryByLabelText('Project/Task')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByLabelText('Worked on multiple tasks?'));
+ expect(screen.getByLabelText('Project/Task')).toBeInTheDocument();
+ expect(axios.post).not.toHaveBeenCalled();
+});
+it('stops only after the batch save and refreshes profile, logs and tasks', async () => {
+ mount();
+ await fill();
+ props.sendStop.mockImplementation(async key => {
+ expect(axios.post).toHaveBeenCalledTimes(1);
+ expect(key).toBe(batchId);
+ return true;
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Submit 2 time entries' }));
+ await waitFor(() => expect(props.toggle).toHaveBeenCalledTimes(1));
+ expect(props.getUserProfile).toHaveBeenCalledWith(personId);
+ expect(props.getTimeEntriesForWeek).toHaveBeenCalledWith(personId, 0);
+ expect(store.getActions()).toContainEqual({ type: 'REFRESH_TASKS', id: personId });
+ expect(props.onTimeSubmitted).toHaveBeenCalledWith({
+ hours: 5,
+ minutes: 0,
+ sessionId: 'client-session',
+ });
+});
+it('recovers an earlier committed split without stopping a new timer', async () => {
+ const payload = {
+ personId,
+ batchId: 'an-earlier-server-key',
+ dateOfWork: '2026-11-05',
+ totalMinutes: 300,
+ allocations: tasks.map((task, index) => ({
+ taskId: task._id,
+ minutes: index ? 120 : 180,
+ notes,
+ })),
+ };
+ localStorage.setItem(
+ splitDraftKey(personId),
+ JSON.stringify({
+ attempted: true,
+ saved: true,
+ payload,
+ receipt: payload,
+ rows: tasks.map((task, index) => ({
+ id: String(index),
+ taskId: task._id,
+ hours: index ? '2' : '3',
+ minutes: '0',
+ notes,
+ })),
+ }),
+ );
+ mount();
+ fireEvent.click(screen.getByRole('button', { name: 'Finish timer' }));
+ await waitFor(() => expect(props.toggle).toHaveBeenCalled());
+ expect(props.sendStop).not.toHaveBeenCalled();
+ expect(axios.post).not.toHaveBeenCalled();
+ expect(props.onTimeSubmitted).not.toHaveBeenCalled();
+});
+it('does not expose splitting when logging for someone else', async () => {
+ sessionStorage.setItem(
+ 'viewingUser',
+ JSON.stringify({ userId: 'ffffffffffffffffffffffff', firstName: 'Another user' }),
+ );
+ mount();
+ await waitFor(() => expect(screen.queryByText('Loading Data...')).not.toBeInTheDocument());
+ expect(screen.queryByLabelText('Worked on multiple tasks?')).not.toBeInTheDocument();
+});
diff --git a/src/components/Timelog/TimeEntryForm/__tests__/splitTimeEntryUtils.test.js b/src/components/Timelog/TimeEntryForm/__tests__/splitTimeEntryUtils.test.js
new file mode 100644
index 00000000000..5069c761d37
--- /dev/null
+++ b/src/components/Timelog/TimeEntryForm/__tests__/splitTimeEntryUtils.test.js
@@ -0,0 +1,7 @@
+import { readSplitDraft, splitDraftKey } from '../splitTimeEntryUtils';
+
+it.each(['not json', '{}', '{"payload":{"personId":"user"},"rows":[null,null]}', '{"payload":{"personId":"another"},"rows":[]}'])('ignores malformed recovery data: %s', value => {
+ localStorage.setItem(splitDraftKey('user'), value);
+ expect(readSplitDraft('user')).toBeNull();
+ localStorage.clear();
+});
diff --git a/src/components/Timelog/TimeEntryForm/splitTimeEntryUtils.js b/src/components/Timelog/TimeEntryForm/splitTimeEntryUtils.js
new file mode 100644
index 00000000000..a5ac4a66b4b
--- /dev/null
+++ b/src/components/Timelog/TimeEntryForm/splitTimeEntryUtils.js
@@ -0,0 +1,59 @@
+export const splitDraftKey = personId => `hgn:split-timer:${personId}`;
+
+export function readSplitDraft(personId) {
+ try {
+ const draft = JSON.parse(localStorage.getItem(splitDraftKey(personId)) || 'null');
+ const validRows =
+ Array.isArray(draft?.rows) &&
+ draft.rows.length >= 2 &&
+ draft.rows.length <= 20 &&
+ draft.rows.every(
+ row =>
+ row &&
+ ['id', 'taskId', 'hours', 'minutes', 'notes'].every(key => typeof row[key] === 'string'),
+ );
+ const validPayload =
+ draft?.payload?.personId === personId &&
+ typeof draft.payload.batchId === 'string' &&
+ typeof draft.payload.dateOfWork === 'string' &&
+ Number.isSafeInteger(draft.payload.totalMinutes) &&
+ draft.payload.totalMinutes > 0;
+ return validPayload && validRows ? draft : null;
+ } catch {
+ return null;
+ }
+}
+
+export function allocationMinutes(row) {
+ if (!/^\d+$/.test(String(row.hours)) || !/^\d+$/.test(String(row.minutes))) return Number.NaN;
+ const hours = Number(row.hours);
+ const minutes = Number(row.minutes);
+ return hours <= 1000 && minutes <= 59 ? hours * 60 + minutes : Number.NaN;
+}
+
+export const formatSplitMinutes = total =>
+ `${Math.floor(Math.abs(total) / 60)}:${String(Math.abs(total) % 60).padStart(2, '0')}`;
+
+export function splitValidation(rows, totalMinutes, taskIds) {
+ const selected = rows.map(row => row.taskId);
+ if (rows.length < 2 || rows.length > 20) return 'Choose between 2 and 20 assigned tasks.';
+ if (selected.some(id => !taskIds.includes(id))) return 'Choose an assigned task for every row.';
+ if (new Set(selected).size !== selected.length) return 'Each task may appear only once.';
+ if (
+ rows.some(row => !Number.isSafeInteger(allocationMinutes(row)) || allocationMinutes(row) <= 0)
+ )
+ return 'Give each task a positive time using whole hours and minutes (0–59).';
+ if (
+ rows.some(
+ row =>
+ row.notes
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean).length < 10 || !/https:\/\/[a-z\d.-]+\.[a-z]{2,}/i.test(row.notes),
+ )
+ )
+ return 'Each task needs at least 10 words describing the work and an https reference link.';
+ if (rows.reduce((sum, row) => sum + allocationMinutes(row), 0) !== totalMinutes)
+ return 'Allocated time must equal the session total.';
+ return '';
+}
diff --git a/src/components/Timer/Timer.jsx b/src/components/Timer/Timer.jsx
index a227f21aec1..f56c797ff0f 100644
--- a/src/components/Timer/Timer.jsx
+++ b/src/components/Timer/Timer.jsx
@@ -136,7 +136,7 @@ function Timer({ authUser, darkMode, isPopout }) {
const sendJsonMessageNoQueue = useCallback(msg => sendJsonMessage(msg, false), [sendJsonMessage]);
const sendStopAndConfirm = useCallback(
- targetUserId =>
+ (targetUserId, loggingSessionId) =>
new Promise(resolve => {
const socket = getWebSocket();
if (!socket || socket.readyState !== ReadyState.OPEN) {
@@ -169,6 +169,7 @@ function Timer({ authUser, darkMode, isPopout }) {
action: 'STOP_TIMER',
...(targetUserId == null ? {} : { userId: targetUserId }),
requestId,
+ ...(loggingSessionId ? { loggingSessionId } : {}),
});
} catch (_error) {
clearTimeout(timeoutId);
@@ -381,8 +382,8 @@ function Timer({ authUser, darkMode, isPopout }) {
sendJsonMessageNoQueue({ action: action.PAUSE_TIMER });
},
sendClear: () => sendJsonMessageNoQueue({ action: action.CLEAR_TIMER }),
- sendStop: async () => {
- const stopped = await sendStopAndConfirm(null);
+ sendStop: async loggingSessionId => {
+ const stopped = await sendStopAndConfirm(null, loggingSessionId);
// Clear the submitted time when stopping
clearSubmittedTime();
return stopped;
@@ -409,8 +410,8 @@ function Timer({ authUser, darkMode, isPopout }) {
},
sendClear: () =>
sendJsonMessageNoQueue({ action: action.CLEAR_TIMER, userId: viewingUserId }),
- sendStop: async () => {
- const stopped = await sendStopAndConfirm(viewingUserId);
+ sendStop: async loggingSessionId => {
+ const stopped = await sendStopAndConfirm(viewingUserId, loggingSessionId);
// Clear the submitted time when stopping
clearSubmittedTime();
return stopped;
@@ -640,7 +641,7 @@ function Timer({ authUser, darkMode, isPopout }) {
const { timeoutId, resolve } = pendingStopRef.current;
clearTimeout(timeoutId);
pendingStopRef.current = null;
- resolve(lastJsonMessage.started === false);
+ resolve(lastJsonMessage.started === false && !lastJsonMessage.stopRejected);
}
// Handle explicit week close pause action messages
if (lastJsonMessage && lastJsonMessage.action === 'WEEK_CLOSE_PAUSE') {
@@ -820,6 +821,7 @@ function Timer({ authUser, darkMode, isPopout }) {
timerConnected={customReadyState === ReadyState.OPEN}
onTimeSubmitted={handleTimeSubmitted}
sessionId={sessionId}
+ timerSessionId={message.loggingSessionId}
timerStats={getTimerStats()}
/>
);
diff --git a/src/utils/URL.js b/src/utils/URL.js
index 57df9efcd63..2f657743a5b 100644
--- a/src/utils/URL.js
+++ b/src/utils/URL.js
@@ -105,6 +105,8 @@ export const ENDPOINTS = {
TIME_ENTRIES_LOST_PROJ_LIST: `${APIEndpoint}/TimeEntry/lostProjects`,
TIME_ENTRIES_LOST_TEAM_LIST: `${APIEndpoint}/TimeEntry/lostTeams`,
TIME_ENTRY: () => `${APIEndpoint}/TimeEntry`,
+ TIME_ENTRY_SPLIT: () => `${APIEndpoint}/TimeEntry/split`,
+ TIME_ENTRY_SPLIT_RECEIPT: batchId => `${APIEndpoint}/TimeEntry/split/${encodeURIComponent(batchId)}`,
TIME_ENTRY_CHANGE: timeEntryId => `${APIEndpoint}/TimeEntry/${timeEntryId}`,
TIMELOG_TRACKING: userId => `${APIEndpoint}/timelogTracking/${userId}`,
WBS_ALL: `${APIEndpoint}/wbs`,