VOOZH about

URL: https://dev.to/dopami/devlog-building-bulk-task-assignment-for-an-adhd-chore-app-17hb

⇱ Devlog: Building Bulk Task Assignment for an ADHD Chore App - DEV Community


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:

  • ExecuteUpdateAsync translates to a single UPDATE ... WHERE id IN (...) — no per-row round-trips, no change tracking overhead.
  • The homeId filter on task.Room.HomeId means 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