diff --git a/src/app/lists/page.tsx b/src/app/lists/page.tsx index 2e769fd..95b0127 100644 --- a/src/app/lists/page.tsx +++ b/src/app/lists/page.tsx @@ -1,7 +1,7 @@ import { ListsIndex } from "@/modules/lists/components/lists-index"; -import { listLists } from "@/modules/lists/server/queries"; +import { listListsWithItems } from "@/modules/lists/server/queries"; export default async function ListsPage() { - const lists = await listLists(); + const lists = await listListsWithItems(); return ; } diff --git a/src/modules/lists/components/list-widget.tsx b/src/modules/lists/components/list-widget.tsx new file mode 100644 index 0000000..1a2ddc9 --- /dev/null +++ b/src/modules/lists/components/list-widget.tsx @@ -0,0 +1,56 @@ +"use client"; + +import Link from "next/link"; +import { useOptimistic, useTransition } from "react"; +import { toggleItem } from "../server/actions"; + +type WidgetItem = { + id: string; + text: string; + done: boolean; + listName: string; + listId: string; +}; + +export function ListWidget({ initialItems }: { initialItems: WidgetItem[] }) { + const [items, setOptimistic] = useOptimistic( + initialItems, + (current: WidgetItem[], { id, done }: { id: string; done: boolean }) => + current.map((item) => (item.id === id ? { ...item, done } : item)), + ); + const [, startTransition] = useTransition(); + + function toggle(item: WidgetItem, done: boolean) { + startTransition(async () => { + setOptimistic({ id: item.id, done }); + await toggleItem({ id: item.id, done }); + }); + } + + if (items.length === 0) { + return

No open items

; + } + + return ( + + ); +} diff --git a/src/modules/lists/components/lists-index.tsx b/src/modules/lists/components/lists-index.tsx index 3dc9320..7c6610f 100644 --- a/src/modules/lists/components/lists-index.tsx +++ b/src/modules/lists/components/lists-index.tsx @@ -1,22 +1,22 @@ "use client"; import Link from "next/link"; -import { Plus } from "lucide-react"; +import { ChevronDown, ChevronRight, ExternalLink, Plus } from "lucide-react"; import { useMemo, useState, useTransition } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import type { ListDto } from "../server/queries"; -import { createList } from "../server/actions"; +import type { ListIndexItem, ListWithItemsDto } from "../server/queries"; +import { createList, toggleItem } from "../server/actions"; -export function ListsIndex({ lists }: { lists: ListDto[] }) { +export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) { const [listRows, setListRows] = useState(lists); const [type, setType] = useState("shopping"); const [name, setName] = useState(""); const [, startTransition] = useTransition(); const grouped = useMemo(() => { - const groups = new Map(); + const groups = new Map(); for (const list of listRows) { groups.set(list.type, [...(groups.get(list.type) ?? []), list]); } @@ -36,12 +36,31 @@ export function ListsIndex({ lists }: { lists: ListDto[] }) { openCount: 0, doneCount: 0, createdAt: created.createdAt.toISOString(), + items: [], }, ]); setName(""); }); } + function handleToggle(listId: string, item: ListIndexItem, done: boolean) { + setListRows((current) => + current.map((list) => + list.id !== listId + ? list + : { + ...list, + openCount: list.openCount + (done ? -1 : 1), + doneCount: list.doneCount + (done ? 1 : -1), + items: list.items.filter((i) => i.id !== item.id), + }, + ), + ); + startTransition(async () => { + await toggleItem({ id: item.id, done }); + }); + } + return (
@@ -81,16 +100,7 @@ export function ListsIndex({ lists }: { lists: ListDto[] }) {
{groupLists.map((list) => ( - -
{list.name}
-
- {list.openCount} open / {list.doneCount} done -
- + ))}
@@ -99,3 +109,76 @@ export function ListsIndex({ lists }: { lists: ListDto[] }) {
); } + +function ListCard({ + list, + onToggle, +}: { + list: ListWithItemsDto; + onToggle: (listId: string, item: ListIndexItem, done: boolean) => void; +}) { + const [expanded, setExpanded] = useState(true); + + return ( +
+
+ +
+
{list.name}
+
+ {list.openCount} open · {list.doneCount} done +
+
+ + + +
+ + {expanded && ( +
+ {list.items.length === 0 ? ( +

+ {list.openCount === 0 ? "All done!" : "No items to show."} +

+ ) : ( +
    + {list.items.map((item) => ( +
  • + onToggle(list.id, item, e.target.checked)} + /> + {item.text} +
  • + ))} + {list.openCount > list.items.length && ( +
  • + + +{list.openCount - list.items.length} more — open list + +
  • + )} +
+ )} +
+ )} +
+ ); +} diff --git a/src/modules/lists/manifest.tsx b/src/modules/lists/manifest.tsx index 3b28468..3b2d626 100644 --- a/src/modules/lists/manifest.tsx +++ b/src/modules/lists/manifest.tsx @@ -3,6 +3,7 @@ import { z } from "zod"; import { listLists, listWidgetItems, searchItems, searchLists } from "./server/queries"; import { loadListForShare, type ListShareData } from "./server/share-queries"; import { ListSharedView } from "./components/shared-view"; +import { ListWidget } from "./components/list-widget"; const listIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]); @@ -12,37 +13,14 @@ const listWidgetConfigSchema = z.object({ limit: z.number().int().min(1).max(50).optional(), }); -async function ListWidget({ config }: { config: unknown; ctx: WidgetContext }) { +async function ListWidgetServer({ config }: { config: unknown; ctx: WidgetContext }) { const parsed = listWidgetConfigSchema.parse(config); const items = await listWidgetItems({ listIds: parsed.listIds, showCompleted: parsed.showCompleted, limit: parsed.limit ?? 10, }); - - if (items.length === 0) { - return ( -

- {parsed.showCompleted ? "No items" : "No open items"} -

- ); - } - - return ( -
    - {items.map((item) => ( -
  • - - - {item.text} - - {item.listName} -
  • - ))} -
- ); + return ; } const manifest: ModuleManifest = { @@ -109,7 +87,7 @@ const manifest: ModuleManifest = { name: list.name, })), }), - render: (props) => , + render: (props) => , }, ], quickAdds: [ diff --git a/src/modules/lists/server/queries.ts b/src/modules/lists/server/queries.ts index 010e893..d60f90f 100644 --- a/src/modules/lists/server/queries.ts +++ b/src/modules/lists/server/queries.ts @@ -156,6 +156,84 @@ export async function searchItems(query: string, householdId: string) { })); } +export type ListIndexItem = { + id: string; + text: string; + done: boolean; + position: number; +}; + +export type ListWithItemsDto = ListDto & { items: ListIndexItem[] }; + +export async function listListsWithItems(): Promise { + const { household } = await getCurrentSession(); + await ensureDefaultListsForHousehold(household.id); + + const listsRows = await db + .select() + .from(lists) + .where(and(eq(lists.householdId, household.id), eq(lists.archived, false))) + .orderBy(asc(lists.type), asc(lists.name)); + + if (listsRows.length === 0) return []; + + const listIds = listsRows.map((l) => l.id); + + // Fetch all open items for these lists, ordered so we can take top 10 per list in JS + const itemRows = await db + .select({ + id: listItems.id, + listId: listItems.listId, + text: listItems.text, + done: listItems.done, + position: listItems.position, + createdAt: listItems.createdAt, + }) + .from(listItems) + .where(and(inArray(listItems.listId, listIds), eq(listItems.done, false))) + .orderBy(asc(listItems.position), asc(listItems.createdAt)); + + const itemsByList = new Map(); + for (const item of itemRows) { + const existing = itemsByList.get(item.listId) ?? []; + if (existing.length < 10) { + existing.push({ id: item.id, text: item.text, done: item.done, position: item.position }); + } + itemsByList.set(item.listId, existing); + } + + // Count all items (open + done) per list + const countRows = await db + .select({ + listId: listItems.listId, + done: listItems.done, + }) + .from(listItems) + .where(inArray(listItems.listId, listIds)); + + const counts = new Map(); + for (const row of countRows) { + const c = counts.get(row.listId) ?? { open: 0, done: 0 }; + if (row.done) c.done += 1; + else c.open += 1; + counts.set(row.listId, c); + } + + return listsRows.map((list) => { + const c = counts.get(list.id) ?? { open: 0, done: 0 }; + return { + id: list.id, + type: list.type, + name: list.name, + archived: list.archived, + openCount: c.open, + doneCount: c.done, + createdAt: list.createdAt.toISOString(), + items: itemsByList.get(list.id) ?? [], + }; + }); +} + export async function listWidgetItems(input: { listIds: "all" | string[]; showCompleted: boolean; @@ -184,6 +262,7 @@ export async function listWidgetItems(input: { id: listItems.id, text: listItems.text, done: listItems.done, + listId: listItems.listId, listName: lists.name, }) .from(listItems)