Skip to main content

max / goingson

Fix bulk-update-tasks: send full TaskInput, report partial failures _applyProject and _applyPriority called update_task with only one field, but the backend validator requires description to be non-empty. The serde default of "" silently triggered VALIDATION_ERROR on every call, breaking both bulk actions. Now fetches each task first and merges the new field with the existing values. Also switches the bulk runner from Promise.all to Promise.allSettled so a single failure no longer hides successful ones — toast distinguishes all-succeeded / all-failed / partial outcomes. Identified by Run 24 (Ultra Fuzz) as SERIOUS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-14 19:26 UTC
Commit: 5cdab3196fe05a22f2818dd0cbcdebb99394dea3
Parent: 32038b9
1 file changed, +48 insertions, -11 deletions
@@ -24,17 +24,24 @@
24 24 if (selectedIds.size === 0) return;
25 25
26 26 const count = selectedIds.size;
27 - try {
28 - const promises = Array.from(selectedIds).map(apiCall);
29 - await Promise.all(promises);
27 + const promises = Array.from(selectedIds).map(apiCall);
28 + const results = await Promise.allSettled(promises);
29 + const succeeded = results.filter(r => r.status === 'fulfilled').length;
30 + const failed = results.filter(r => r.status === 'rejected').length;
31 +
32 + if (failed === 0) {
30 33 GoingsOn.ui.showToast(successMessage.replace('{count}', count));
31 - selectedIds.clear();
32 - if (closeModalAfter) GoingsOn.ui.closeModal();
33 - reloadFn();
34 - updateBulkActionsBar();
35 - } catch (err) {
36 - GoingsOn.ui.showToast(`${errorMessage}: ${GoingsOn.utils.getErrorMessage(err)}`, 'error');
34 + } else if (succeeded === 0) {
35 + const firstErr = results.find(r => r.status === 'rejected');
36 + GoingsOn.ui.showToast(`${errorMessage}: ${GoingsOn.utils.getErrorMessage(firstErr.reason)}`, 'error');
37 + } else {
38 + GoingsOn.ui.showToast(`${succeeded} succeeded, ${failed} failed`, 'warning');
37 39 }
40 +
41 + selectedIds.clear();
42 + if (closeModalAfter) GoingsOn.ui.closeModal();
43 + reloadFn();
44 + updateBulkActionsBar();
38 45 }
39 46
40 47 /**
@@ -177,7 +184,22 @@
177 184 const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set();
178 185 await executeBulkAction({
179 186 selectedIds: selectedTaskIds,
180 - apiCall: id => GoingsOn.api.tasks.update(id, { projectId }),
187 + apiCall: async (id) => {
188 + const task = await GoingsOn.api.tasks.get(id);
189 + if (!task) return;
190 + return GoingsOn.api.tasks.update(id, {
191 + description: task.description,
192 + priority: task.priority,
193 + status: task.status,
194 + projectId: projectId,
195 + due: task.due,
196 + tags: task.tags,
197 + recurrence: task.recurrence,
198 + estimatedMinutes: task.estimatedMinutes,
199 + contactId: task.contactId,
200 + milestoneId: task.milestoneId,
201 + });
202 + },
181 203 successMessage: 'Updated project for {count} tasks',
182 204 errorMessage: 'Failed to update some tasks',
183 205 reloadFn: () => GoingsOn.tasks.load(),
@@ -189,7 +211,22 @@
189 211 const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set();
190 212 await executeBulkAction({
191 213 selectedIds: selectedTaskIds,
192 - apiCall: id => GoingsOn.api.tasks.update(id, { priority }),
214 + apiCall: async (id) => {
215 + const task = await GoingsOn.api.tasks.get(id);
216 + if (!task) return;
217 + return GoingsOn.api.tasks.update(id, {
218 + description: task.description,
219 + priority: priority,
220 + status: task.status,
221 + projectId: task.projectId,
222 + due: task.due,
223 + tags: task.tags,
224 + recurrence: task.recurrence,
225 + estimatedMinutes: task.estimatedMinutes,
226 + contactId: task.contactId,
227 + milestoneId: task.milestoneId,
228 + });
229 + },
193 230 successMessage: 'Updated priority for {count} tasks',
194 231 errorMessage: 'Failed to update some tasks',
195 232 reloadFn: () => GoingsOn.tasks.load(),