TL;DR: Users had to assign chores one-by-one, which is exhausting with ADHD. I added bulk selection + a single batch endpoint. Key decision: batch API over client-side loop for atomic UX and clean error handling.
The Problem
Before this feature, assigning tasks in Dopami required opening each task individually. For a household with 20 weekly chores, that's 20 taps just to distribute work. For users with ADHD, that repetitive loop is more than annoying — it's a pattern that kills motivation mid-task.
Most apps treat this as acceptable. It isn't.
UX Design: Tap-to-Select with a Sticky Footer
The interaction model is simple: tap a task to toggle it in/out of the selection. A sticky footer appears as soon as the first item is selected, showing the count and an "Assign" button. No long-press mode, no modal to open first.
// BulkAssignScreen.tsx
const [selected, setSelected] = useState<Set<number>>(new Set())
const toggle = (taskId: number) => {
setSelected((current) => {
const next = new Set(current)
if (next.has(taskId)) next.delete(taskId)
else next.add(taskId)
return next
})
void Haptics.selectionAsync()
}
Each tap also fires a haptic (selectionAsync) — a small but meaningful signal that the tap registered.
The footer only mounts when selected.size > 0:
{selected.size > 0 ? (
<View style={styles.footer}>
<Text style={styles.footerMeta}>{t('bulkAssign.selectedCount', { count: selected.size })}</Text>
<Button
label={t('bulkAssign.assignAction')}
onPress={pickAssignee}
loading={assignMutation.isPending}
style={styles.footerButton}
/>
</View>
) : null}
No hidden menus. No multi-step confirmation. The count lives in the footer so you always know the selection state without reading individual cards.
Assignee Picker: Native Alert
Clicking "Assign" opens a native Alert listing the household members plus a "Nobody" option (to unassign):
const pickAssignee = () => {
Alert.alert(t('bulkAssign.assignTo'), undefined, [
...members.map((member) => ({
text: member.username,
onPress: () => assignMutation.mutate(member.userId),
})),
{ text: t('bulkAssign.nobody'), onPress: () => assignMutation.mutate(null) },
{ text: t('common.cancel'), style: 'cancel' as const },
])
}
userId: null means "unassign" — the same endpoint handles both operations.
Implementation: Batch Endpoint vs. Client Loop
Option A — Client-side loop (simpler, rejected)
// N requests, N points of failure, visible one-by-one update
const assignAll = async (taskIds: number[], userId: number | null) => {
for (const id of taskIds) {
await houseService.assignTask(id, userId)
}
}
Problems:
- N network requests = N points of failure
- UI updates trickle in one-by-one → ripple effect (bad for perceived speed)
- Partial failure is ambiguous and hard to recover from
Option B — Single batch endpoint (chosen)
// taskService.ts
assignBulk(taskIds: number[], userId: number | null): Promise<{ assigned: number }> {
return api.post<{ assigned: number }>('/api/tasks/assign-bulk', { taskIds, userId })
.then((r) => r.data)
},
houseService exposes it directly as a facade over taskService:
// houseService.ts
assignBulk: taskService.assignBulk.bind(taskService),
The mutation in the screen:
const assignMutation = useMutation({
mutationFn: (userId: number | null) =>
houseService.assignBulk([...selected], userId),
onSuccess: async (result) => {
await Promise.allSettled([
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.allRooms() }),
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.activeRooms() }),
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.assignedTasks() }),
])
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
showToast(t('bulkAssign.success', { count: result.assigned }), 'success')
router.back()
},
onError: () => showToast(t('bulkAssign.error'), 'error'),
})
Three query keys are invalidated in parallel (Promise.allSettled) so every list that shows tasks refreshes at once. Success fires a NotificationFeedbackType.Success haptic — a heavier, more satisfying pulse than the selection tick.
Backend: One UPDATE, Scoped to the Household
The request model is minimal:
// AssignBulkRequest.cs
public class AssignBulkRequest
{
public List<int> TaskIds { get; set; } = [];
public int? UserId { get; set; } // null = unassign
}
The controller delegates straight to the service:
// HouseController.cs
[HttpPost("tasks/assign-bulk")]
public async Task<ActionResult> AssignTasksBulk([FromBody] AssignBulkRequest request)
{
var assigned = await taskService.AssignUsersBulkAsync(
request.TaskIds, GetCurrentUserHomeId(), request.UserId);
return assigned is null
? BadRequest(new { detail = "Utilisateur invalide." })
: Ok(new { assigned = assigned.Value });
}
GetCurrentUserHomeId() comes from the JWT — the client never sends a homeId, so a user can only modify tasks in their own household.
The service runs it as a single EF Core ExecuteUpdateAsync:
// TaskService.cs
public async Task<int?> AssignUsersBulkAsync(
IReadOnlyCollection<int> taskIds, int homeId, int? userId)
{
var ids = taskIds.Distinct().ToList();
if (ids.Count == 0) return 0;
await using var context = await factory.CreateDbContextAsync();
if (userId.HasValue
&& !await context.Users.AnyAsync(u => u.Id == userId.Value && u.HomeId == homeId))
return null; // user not in this household
return await context.HouseTasks
.Where(task => ids.Contains(task.Id) && task.Room.HomeId == homeId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.UserAssignedId, userId));
}
Two things worth noting:
-
ExecuteUpdateAsynctranslates to a singleUPDATE ... WHERE id IN (...)— no per-row round-trips, no change tracking overhead. - The
homeIdfilter ontask.Room.HomeIdmeans passing arbitrary task IDs from another household silently does nothing. No explicit auth check needed beyond "is this your household?"
null return = the target user doesn't belong to this household. int return = how many rows were updated, echoed back to the client as { assigned: N }.
Why Atomicity Matters for ADHD UX
The dopamine loop in a chore app depends on a clear cause → effect: I did the action → I see the result. A ripple update (15 items changing one-by-one over 750ms) breaks that loop. It communicates "this is working through a queue" rather than "done."
A single batch operation gives you:
- Instant visual confirmation (one spinner → success haptic → everything refreshes)
- No partial state to reconcile
- Clean error path: show one error toast, the selection is still intact, the user can retry
For neurotypical users this is a polish detail. For ADHD users it's the difference between a satisfying interaction and an anxious one.
Takeaway
If your app has any "do X to each item" flow, ask whether a batch operation serves the user better. The implementation cost is one endpoint and a single ExecuteUpdateAsync. The UX benefit — especially for users with executive dysfunction — is significant.
Try Dopami in beta → do-pami.com/beta
For further actions, you may consider blocking this person and/or reporting abuse
