From 5cfcce0b647359c54306e6d6519207c1f67037c2 Mon Sep 17 00:00:00 2001 From: bizzle Date: Thu, 24 Sep 2026 20:01:59 -0400 Subject: [PATCH] fix: keep entry form mounted when a new entry is first saved On mobile, typing the first characters of a work description on a new entry caused the form to remount: the auto-save POST replaced the temp entry with the server copy, changing its React key, which destroyed the textarea, dropped focus and closed the keyboard. - Key EntryForm on a stable clientKey that survives the id swap - Merge only the server id into the local entry so keystrokes typed while the create request is in flight are kept - Track temp-id -> real-id and in-flight creates so queued changes become PUTs instead of duplicate POSTs; deletes resolve to the real row Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/DayCard.jsx | 2 +- frontend/src/pages/Timesheet.jsx | 91 +++++++++++++++++++++-------- 2 files changed, 69 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/DayCard.jsx b/frontend/src/components/DayCard.jsx index a2d033c..04ed16d 100644 --- a/frontend/src/components/DayCard.jsx +++ b/frontend/src/components/DayCard.jsx @@ -65,7 +65,7 @@ export default function DayCard({ date, entries, homeowners, onEntryChange, onAd
{(entries || []).map((entry) => ( realId (after create resolves) + // creating: tempId -> Promise (while create is in flight) + const idMap = useRef({}); + const creating = useRef({}); + + function makeNewEntry(date) { + const id = `new-${date}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + return { id, clientKey: id, date, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }; + } const weekDates = getWeekDates(selectedDate); const weekParam = formatWeekParam(selectedDate); @@ -85,10 +95,12 @@ export default function Timesheet() { // Ensure at least one empty entry per day weekDates.forEach((d) => { if (byDate[d].length === 0) { - byDate[d] = [{ id: `new-${d}-0`, date: d, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }]; + byDate[d] = [makeNewEntry(d)]; } }); + idMap.current = {}; + creating.current = {}; setEntries(byDate); setTimesheet(timesheetRes.data?.timesheet || timesheetRes.data); setHomeowners(homeownersRes.data.homeowners || homeownersRes.data || []); @@ -124,18 +136,51 @@ export default function Timesheet() { }; try { - if (data._isNew || entryId.startsWith('new-')) { - const res = await api.post('/entries', payload); - // Replace temp ID with real ID - setEntries((prev) => { - const dk = data.date; - return { - ...prev, - [dk]: prev[dk].map((e) => (e.id === entryId ? { ...res.data.entry || res.data, date: dk } : e)), - }; + const isTemp = entryId.startsWith('new-'); + let realId = isTemp ? idMap.current[entryId] : entryId; + + // A create for this temp id is already in flight — wait for it, then update. + if (isTemp && !realId && creating.current[entryId]) { + realId = await creating.current[entryId]; + } + + if (isTemp && !realId) { + const createPromise = api.post('/entries', payload).then((res) => { + const created = res.data.entry || res.data; + idMap.current[entryId] = created.id; + // Merge the server id into the existing local entry. Keep the + // clientKey (so React does not remount the form) and keep whatever + // the user has typed since the request was sent. + setEntries((prev) => { + const dk = data.date; + return { + ...prev, + [dk]: (prev[dk] || []).map((e) => + e.id === entryId + ? { + ...e, + id: created.id, + clientKey: e.clientKey, + date: dk, + homeownerId: e.homeownerId, + hoursWorked: e.hoursWorked, + workDescription: e.workDescription, + _isNew: false, + } + : e + ), + }; + }); + return created.id; }); + creating.current[entryId] = createPromise; + try { + await createPromise; + } finally { + delete creating.current[entryId]; + } } else { - await api.put(`/entries/${entryId}`, payload); + await api.put(`/entries/${realId}`, payload); } setSaveStatus('saved'); } catch (err) { @@ -164,27 +209,27 @@ export default function Timesheet() { } function handleAddEntry(date) { - const newEntry = { - id: `new-${date}-${Date.now()}`, - date, - homeownerId: '', - hoursWorked: '', - workDescription: '', - _isNew: true, - }; + const newEntry = makeNewEntry(date); setEntries((prev) => ({ ...prev, [date]: [...(prev[date] || []), newEntry] })); } async function handleDeleteEntry(entryId) { try { - if (!entryId.startsWith('new-')) { - await api.delete(`/entries/${entryId}`); + // Resolve a temp id to its server id if the create already happened + // (or is still in flight) so we do not orphan a row on the server. + let realId = entryId; + if (entryId.startsWith('new-')) { + realId = idMap.current[entryId] || (creating.current[entryId] ? await creating.current[entryId] : null); } + if (realId) { + await api.delete(`/entries/${realId}`); + } + delete pendingChanges.current[entryId]; setEntries((prev) => { const updated = {}; Object.entries(prev).forEach(([dk, dayEntries]) => { - const filtered = dayEntries.filter((e) => e.id !== entryId); - updated[dk] = filtered.length > 0 ? filtered : [{ id: `new-${dk}-0`, date: dk, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }]; + const filtered = dayEntries.filter((e) => e.id !== entryId && e.id !== realId); + updated[dk] = filtered.length > 0 ? filtered : [makeNewEntry(dk)]; }); return updated; });