feat: live updates on shared shopping lists

Polls item state every 4s and merges it into the list view, guarding
any item with an in-flight local edit so a poll landing mid-edit can't
stomp on it. No websocket/SSE infra exists in this app yet, so this
follows the same polling convention already used for notifications and
message threads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-13 12:27:48 +02:00
parent 70eb565eec
commit b1f4bba6dd
6 changed files with 93 additions and 4 deletions
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import { Check, Package, Loader2, GripVertical, MoreVertical, Trash2, Search, Sparkles } from "lucide-react";
@@ -98,6 +98,44 @@ export function ShoppingListView({
const [deleting, setDeleting] = useState(false);
const [autoCategorizing, setAutoCategorizing] = useState(false);
// Live updates: other collaborators' edits are picked up by polling rather than
// pushed, so incoming server snapshots must not stomp on this tab's own in-flight
// optimistic edits. dirtyUntilRef marks an item as "ours, don't overwrite" for a
// few seconds after a local mutation; pendingDeleteIdsRef additionally hides items
// this tab is deleting, since the server won't reflect that until the DELETE lands.
const dirtyUntilRef = useRef<Map<string, number>>(new Map());
const pendingDeleteIdsRef = useRef<Set<string>>(new Set());
const isDraggingRef = useRef(false);
const DIRTY_MS = 4000;
function markDirty(...ids: string[]) {
const until = Date.now() + DIRTY_MS;
for (const id of ids) dirtyUntilRef.current.set(id, until);
}
useEffect(() => {
const interval = setInterval(() => {
if (isDraggingRef.current) return;
fetch(`/api/v1/shopping-lists/${listId}/items`)
.then((res) => (res.ok ? res.json() : null))
.then((data: { items: Item[] } | null) => {
if (!data) return;
const now = Date.now();
setItems((prev) => {
const prevById = new Map(prev.map((i) => [i.id, i]));
return data.items
.filter((s) => !pendingDeleteIdsRef.current.has(s.id))
.map((s) => {
const dirty = (dirtyUntilRef.current.get(s.id) ?? 0) > now;
return dirty ? (prevById.get(s.id) ?? s) : s;
});
});
})
.catch(() => {});
}, 4000);
return () => clearInterval(interval);
}, [listId]);
// Predefined categories, plus any custom category already in use on this list
// (so a custom category someone created stays selectable for other items too),
// plus "Other" (represented as `null` under the hood) always last.
@@ -147,6 +185,7 @@ export function ShoppingListView({
async function toggleItem(item: Item) {
if (readOnly) return;
const next = !item.checked;
markDirty(item.id);
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: next } : i));
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
@@ -164,6 +203,7 @@ export function ShoppingListView({
async function changeCategory(item: Item, nextAisle: string | null) {
if (readOnly) return;
const prevAisle = item.aisle;
markDirty(item.id);
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: nextAisle } : i));
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
@@ -181,6 +221,7 @@ export function ShoppingListView({
async function deleteItem(item: Item) {
if (readOnly) return;
setDeleting(true);
pendingDeleteIdsRef.current.add(item.id);
const prevItems = items;
setItems((prev) => prev.filter((i) => i.id !== item.id));
try {
@@ -188,6 +229,7 @@ export function ShoppingListView({
if (!res.ok) throw new Error();
setDeleteTarget(null);
} catch {
pendingDeleteIdsRef.current.delete(item.id);
setItems(prevItems);
toast.error(t("removeFailed"));
} finally {
@@ -213,6 +255,7 @@ export function ShoppingListView({
}
const byId = new Map(updates.map((u) => [u.id, u.aisle]));
markDirty(...updates.map((u) => u.id));
setItems((prev) => prev.map((i) => byId.has(i.id) ? { ...i, aisle: byId.get(i.id)! } : i));
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
@@ -233,6 +276,7 @@ export function ShoppingListView({
// request (rather than firing one PUT per dragged item).
async function persistOrder(reordered: Item[]) {
const updates = reordered.map((item, index) => ({ id: item.id, sortOrder: index }));
markDirty(...updates.map((u) => u.id));
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
method: "PATCH",
@@ -254,6 +298,7 @@ export function ShoppingListView({
// onDragOver. Reverts on failure (only the field, not its position — a rare-case
// rollback, not worth re-deriving the exact prior array position for).
async function persistAisleChange(itemId: string, nextAisle: string | null, prevAisle: string | null) {
markDirty(itemId);
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${itemId}`, {
method: "PUT",
@@ -268,6 +313,7 @@ export function ShoppingListView({
}
function handleDragStart(event: DragStartEvent) {
isDraggingRef.current = true;
const activeItem = items.find((i) => i.id === event.active.id);
dragStartAisleRef.current = activeItem?.aisle ?? null;
}
@@ -304,6 +350,7 @@ export function ShoppingListView({
// position within that group and persists whatever actually changed: the new order,
// and — only if the category actually changed since drag start — the new aisle.
function handleDragEnd(event: DragEndEvent) {
isDraggingRef.current = false;
const { active, over } = event;
const startAisle = dragStartAisleRef.current;
dragStartAisleRef.current = null;