From b1f4bba6dd250f31966fa02c081950e9c547aef3 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 13 Jul 2026 12:27:48 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 5 ++ .../api/v1/shopping-lists/[id]/items/route.ts | 30 ++++++++++++ .../meal-plan/shopping-list-view.tsx | 49 ++++++++++++++++++- apps/web/lib/changelog.ts | 9 +++- apps/web/package.json | 2 +- package.json | 2 +- 6 files changed, 93 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd6690..ddf88d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.11.0 — 2026-07-13 12:27 + +### Added +- **Live updates on shared shopping lists**: items checked, added, recategorized, or reordered by a collaborator now appear automatically, without a manual refresh. + ## 0.10.0 — 2026-07-13 11:55 ### Added diff --git a/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts index 6daf0f5..d401233 100644 --- a/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts +++ b/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts @@ -6,6 +6,36 @@ import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list import { guessAisle } from "@/lib/grocery-categories"; import { notifyShoppingListMembers } from "@/lib/shopping-list-notify"; +// Polled by the list view (components/meal-plan/shopping-list-view.tsx) so +// collaborators see each other's checked/added/reordered items without a +// manual refresh — no push/websocket infra in this app, so a short poll on +// the same shape the page's initial server-render already uses. +export async function GET(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { id } = await params; + + const access = await getShoppingListAccess(id, session!.user.id); + if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const items = await db.query.shoppingListItems.findMany({ + where: eq(shoppingListItems.listId, id), + orderBy: (t, { asc }) => [asc(t.aisle), asc(t.sortOrder), asc(t.rawName)], + }); + + return NextResponse.json({ + items: items.map((i) => ({ + id: i.id, + rawName: i.rawName, + quantity: i.quantity, + unit: i.unit, + aisle: i.aisle, + checked: i.checked, + sortOrder: i.sortOrder, + })), + }); +} + const AddItemsSchema = z.object({ items: z.array(z.object({ rawName: z.string().min(1), diff --git a/apps/web/components/meal-plan/shopping-list-view.tsx b/apps/web/components/meal-plan/shopping-list-view.tsx index 7f42b86..10a3aaa 100644 --- a/apps/web/components/meal-plan/shopping-list-view.tsx +++ b/apps/web/components/meal-plan/shopping-list-view.tsx @@ -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>(new Map()); + const pendingDeleteIdsRef = useRef>(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; diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 5abd494..eb02d1f 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.10.0"; +export const APP_VERSION = "0.11.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.11.0", + date: "2026-07-13 12:27", + added: [ + "**Live updates on shared shopping lists**: items checked, added, recategorized, or reordered by a collaborator now appear automatically, without a manual refresh.", + ], + }, { version: "0.10.0", date: "2026-07-13 11:55", diff --git a/apps/web/package.json b/apps/web/package.json index 63f4f5b..27d7501 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.10.0", + "version": "0.11.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 241fc96..149d69a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.10.0", + "version": "0.11.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",