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:
@@ -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.
|
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
|
## 0.10.0 — 2026-07-13 11:55
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -6,6 +6,36 @@ import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list
|
|||||||
import { guessAisle } from "@/lib/grocery-categories";
|
import { guessAisle } from "@/lib/grocery-categories";
|
||||||
import { notifyShoppingListMembers } from "@/lib/shopping-list-notify";
|
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({
|
const AddItemsSchema = z.object({
|
||||||
items: z.array(z.object({
|
items: z.array(z.object({
|
||||||
rawName: z.string().min(1),
|
rawName: z.string().min(1),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Check, Package, Loader2, GripVertical, MoreVertical, Trash2, Search, Sparkles } from "lucide-react";
|
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 [deleting, setDeleting] = useState(false);
|
||||||
const [autoCategorizing, setAutoCategorizing] = 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
|
// Predefined categories, plus any custom category already in use on this list
|
||||||
// (so a custom category someone created stays selectable for other items too),
|
// (so a custom category someone created stays selectable for other items too),
|
||||||
// plus "Other" (represented as `null` under the hood) always last.
|
// plus "Other" (represented as `null` under the hood) always last.
|
||||||
@@ -147,6 +185,7 @@ export function ShoppingListView({
|
|||||||
async function toggleItem(item: Item) {
|
async function toggleItem(item: Item) {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
const next = !item.checked;
|
const next = !item.checked;
|
||||||
|
markDirty(item.id);
|
||||||
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: next } : i));
|
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: next } : i));
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
|
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) {
|
async function changeCategory(item: Item, nextAisle: string | null) {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
const prevAisle = item.aisle;
|
const prevAisle = item.aisle;
|
||||||
|
markDirty(item.id);
|
||||||
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: nextAisle } : i));
|
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, aisle: nextAisle } : i));
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
|
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
|
||||||
@@ -181,6 +221,7 @@ export function ShoppingListView({
|
|||||||
async function deleteItem(item: Item) {
|
async function deleteItem(item: Item) {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
|
pendingDeleteIdsRef.current.add(item.id);
|
||||||
const prevItems = items;
|
const prevItems = items;
|
||||||
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
setItems((prev) => prev.filter((i) => i.id !== item.id));
|
||||||
try {
|
try {
|
||||||
@@ -188,6 +229,7 @@ export function ShoppingListView({
|
|||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
} catch {
|
} catch {
|
||||||
|
pendingDeleteIdsRef.current.delete(item.id);
|
||||||
setItems(prevItems);
|
setItems(prevItems);
|
||||||
toast.error(t("removeFailed"));
|
toast.error(t("removeFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -213,6 +255,7 @@ export function ShoppingListView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const byId = new Map(updates.map((u) => [u.id, u.aisle]));
|
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));
|
setItems((prev) => prev.map((i) => byId.has(i.id) ? { ...i, aisle: byId.get(i.id)! } : i));
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
|
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).
|
// request (rather than firing one PUT per dragged item).
|
||||||
async function persistOrder(reordered: Item[]) {
|
async function persistOrder(reordered: Item[]) {
|
||||||
const updates = reordered.map((item, index) => ({ id: item.id, sortOrder: index }));
|
const updates = reordered.map((item, index) => ({ id: item.id, sortOrder: index }));
|
||||||
|
markDirty(...updates.map((u) => u.id));
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
|
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -254,6 +298,7 @@ export function ShoppingListView({
|
|||||||
// onDragOver. Reverts on failure (only the field, not its position — a rare-case
|
// onDragOver. Reverts on failure (only the field, not its position — a rare-case
|
||||||
// rollback, not worth re-deriving the exact prior array position for).
|
// rollback, not worth re-deriving the exact prior array position for).
|
||||||
async function persistAisleChange(itemId: string, nextAisle: string | null, prevAisle: string | null) {
|
async function persistAisleChange(itemId: string, nextAisle: string | null, prevAisle: string | null) {
|
||||||
|
markDirty(itemId);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${itemId}`, {
|
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${itemId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -268,6 +313,7 @@ export function ShoppingListView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDragStart(event: DragStartEvent) {
|
function handleDragStart(event: DragStartEvent) {
|
||||||
|
isDraggingRef.current = true;
|
||||||
const activeItem = items.find((i) => i.id === event.active.id);
|
const activeItem = items.find((i) => i.id === event.active.id);
|
||||||
dragStartAisleRef.current = activeItem?.aisle ?? null;
|
dragStartAisleRef.current = activeItem?.aisle ?? null;
|
||||||
}
|
}
|
||||||
@@ -304,6 +350,7 @@ export function ShoppingListView({
|
|||||||
// position within that group and persists whatever actually changed: the new order,
|
// 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.
|
// and — only if the category actually changed since drag start — the new aisle.
|
||||||
function handleDragEnd(event: DragEndEvent) {
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
isDraggingRef.current = false;
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
const startAisle = dragStartAisleRef.current;
|
const startAisle = dragStartAisleRef.current;
|
||||||
dragStartAisleRef.current = null;
|
dragStartAisleRef.current = null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.10.0",
|
||||||
date: "2026-07-13 11:55",
|
date: "2026-07-13 11:55",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.10.0",
|
"version": "0.11.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.10.0",
|
"version": "0.11.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user