From f09d4b140a356099f29a498f889d3a7dc95cbdea Mon Sep 17 00:00:00 2001 From: Aditya Varun Dhayapulay <93042524+adit24dhaya@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:14:12 -0800 Subject: [PATCH 1/2] Add optional timer splitting across assigned tasks Implement the Phase I Split One Timer Session Across Multiple Assigned Tasks request. Let users allocate the session total with separate task descriptions, preserve uncertain saves for safe retry and confirm timer completion without reposting entries. Task: HGN Phase I Bugs and Needed Functionalities, Split One Timer Session Across Multiple Assigned Tasks Validation: 46 frontend tests, 13 browser cases, production build and changed-file JS/CSS lint. --- .../TimeEntryForm/SplitTimeEntryForm.jsx | 320 ++++++++++++++++++ .../SplitTimeEntryForm.module.css | 139 ++++++++ .../Timelog/TimeEntryForm/TimeEntryForm.jsx | 78 ++++- .../__tests__/SplitTimeEntryForm.test.jsx | 197 +++++++++++ .../__tests__/TimeEntryForm.split.test.jsx | 148 ++++++++ .../__tests__/splitTimeEntryUtils.test.js | 7 + .../TimeEntryForm/splitTimeEntryUtils.js | 59 ++++ src/components/Timer/Timer.jsx | 14 +- src/utils/URL.js | 2 + 9 files changed, 952 insertions(+), 12 deletions(-) create mode 100644 src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.jsx create mode 100644 src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.module.css create mode 100644 src/components/Timelog/TimeEntryForm/__tests__/SplitTimeEntryForm.test.jsx create mode 100644 src/components/Timelog/TimeEntryForm/__tests__/TimeEntryForm.split.test.jsx create mode 100644 src/components/Timelog/TimeEntryForm/__tests__/splitTimeEntryUtils.test.js create mode 100644 src/components/Timelog/TimeEntryForm/splitTimeEntryUtils.js diff --git a/src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.jsx b/src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.jsx new file mode 100644 index 00000000000..3cb01a03d2f --- /dev/null +++ b/src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.jsx @@ -0,0 +1,320 @@ +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 (failure) { + if (failure.response?.status !== 409) throw failure; + // Another tab may already have saved this server-issued timer session. + return axios.get(ENDPOINTS.TIME_ENTRY_SPLIT_RECEIPT(payload.batchId)); + } +} + +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 (failure) { + if (!current.saved && [400, 403, 404].includes(failure.response?.status)) { + current = { ...current, attempted: false }; + try { + persist(current); + } catch { + setDraft(current); + } + onLockChange(false); + } + setError( + failure.response?.data?.error || + failure.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 && ( +

+ {draft.saved + ? 'These entries are already saved. Finish the timer to complete.' + : 'Confirming this split. Your original allocations are kept for a safe retry.'} +

+ )} +
+ Task allocations + {draft.rows.map((row, index) => ( +
+ + +
+ + +
+ +