Implement lists module and dev login setup

This commit is contained in:
ginnoir
2026-05-06 03:49:03 -05:00
parent 744c1119a9
commit 7e3ae6eb04
31 changed files with 1525 additions and 63 deletions
@@ -4,11 +4,7 @@ import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import type {
DateSelectArg,
EventClickArg,
EventDropArg,
} from "@fullcalendar/core";
import type { DateSelectArg, EventClickArg, EventDropArg } from "@fullcalendar/core";
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
@@ -221,9 +217,7 @@ export function CalendarShell({
function updateCalendar(calendar: CalendarDto, values: Partial<CalendarDto>) {
const next = { ...calendar, ...values };
setCalendarRows((current) =>
current.map((item) => (item.id === calendar.id ? next : item)),
);
setCalendarRows((current) => current.map((item) => (item.id === calendar.id ? next : item)));
startTransition(async () => {
if (values.name !== undefined) {
await renameCalendar({ id: calendar.id, name: values.name });
@@ -367,7 +361,7 @@ export function CalendarShell({
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={addCalendar} disabled={!calendarName || isPending}>
<Button className="w-full" onClick={addCalendar} disabled={!calendarName}>
<Check />
Create calendar
</Button>
+25 -19
View File
@@ -14,20 +14,31 @@ const calendarInput = z.object({
visibility: z.enum(["private", "household"]).default("household"),
});
const eventInput = z
.object({
calendarId: z.string().uuid(),
title: z.string().trim().min(1).max(200),
startAt: z.coerce.date(),
endAt: z.coerce.date(),
allDay: z.boolean().default(false),
location: z.string().trim().max(300).nullable().optional(),
notes: z.string().trim().max(3000).nullable().optional(),
})
.refine((value) => value.endAt >= value.startAt, {
const eventBaseInput = z.object({
calendarId: z.string().uuid(),
title: z.string().trim().min(1).max(200),
startAt: z.coerce.date(),
endAt: z.coerce.date(),
allDay: z.boolean().default(false),
location: z.string().trim().max(300).nullable().optional(),
notes: z.string().trim().max(3000).nullable().optional(),
});
const eventInput = eventBaseInput.refine((value) => value.endAt >= value.startAt, {
path: ["endAt"],
message: "End must be after start",
});
const eventUpdateInput = eventBaseInput.partial().refine(
(value) => {
if (!value.startAt || !value.endAt) return true;
return value.endAt >= value.startAt;
},
{
path: ["endAt"],
message: "End must be after start",
});
},
);
export async function createCalendar(input: z.input<typeof calendarInput>) {
const parsed = calendarInput.parse(input);
@@ -79,9 +90,7 @@ export async function setCalendarVisibility(input: {
export async function setCalendarColor(input: { id: string; color: string | null }) {
const { user } = await getCurrentSession();
const parsed = z
.object({ id: z.string().uuid(), color: calendarInput.shape.color })
.parse(input);
const parsed = z.object({ id: z.string().uuid(), color: calendarInput.shape.color }).parse(input);
await assertOwnsCalendar(user.id, parsed.id);
await db
.update(calendars)
@@ -124,10 +133,7 @@ export async function createEvent(input: z.input<typeof eventInput>) {
}
export async function updateEvent(input: { id: string } & Partial<z.input<typeof eventInput>>) {
const parsed = z
.object({ id: z.string().uuid() })
.and(eventInput.partial())
.parse(input);
const parsed = z.object({ id: z.string().uuid() }).and(eventUpdateInput).parse(input);
const { user } = await getCurrentSession();
const [existing] = await db
.select({ calendarId: calendarEvents.calendarId })
@@ -0,0 +1,189 @@
"use client";
import { useRouter } from "next/navigation";
import { Archive, GripVertical, Plus, Trash2 } from "lucide-react";
import { useEffect, useRef, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { ListDetailDto, ListItemDto } from "../server/queries";
import { getList } from "../server/queries";
import {
addItem,
archiveList,
deleteItem,
renameList,
toggleItem,
updateItem,
} from "../server/actions";
export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
const router = useRouter();
const [list, setList] = useState(initialList);
const [draft, setDraft] = useState("");
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
const swipeStart = useRef<Record<string, number>>({});
useEffect(() => {
const events = new EventSource(`/api/lists/${initialList.id}/events`);
events.onmessage = () => {
getList(initialList.id)
.then(setList)
.catch(() => undefined);
};
return () => events.close();
}, [initialList.id]);
function submitItem() {
const text = draft.trim();
if (!text) return;
startTransition(async () => {
const next = await addItem({ listId: list.id, text });
setList(next);
setDraft("");
requestAnimationFrame(() => inputRef.current?.focus());
});
}
function setItemDone(item: ListItemDto, done: boolean) {
setList((current) => ({
...current,
items: current.items.map((row) => (row.id === item.id ? { ...row, done } : row)),
}));
startTransition(async () => {
setList(await toggleItem({ id: item.id, done }));
});
}
function editItemText(item: ListItemDto, text: string) {
setList((current) => ({
...current,
items: current.items.map((row) => (row.id === item.id ? { ...row, text } : row)),
}));
}
function commitItemText(item: ListItemDto) {
if (!item.text.trim()) return;
startTransition(async () => {
setList(await updateItem({ id: item.id, text: item.text }));
});
}
function removeItem(item: ListItemDto) {
setList((current) => ({
...current,
items: current.items.filter((row) => row.id !== item.id),
}));
startTransition(async () => {
setList(await deleteItem({ id: item.id }));
});
}
function commitListName() {
if (!list.name.trim()) return;
startTransition(async () => {
await renameList({ id: list.id, name: list.name });
});
}
function archiveCurrentList() {
startTransition(async () => {
await archiveList({ id: list.id });
router.push("/lists");
});
}
return (
<div className="mx-auto grid w-full max-w-3xl gap-5 p-4">
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<Input
aria-label="List name"
className="h-auto border-transparent px-0 text-2xl font-semibold shadow-none focus-visible:border-transparent focus-visible:ring-0"
value={list.name}
onChange={(event) => setList({ ...list, name: event.target.value })}
onBlur={commitListName}
/>
<div className="text-sm text-muted-foreground">
{list.type} / {list.openCount} open / {list.doneCount} done
</div>
</div>
<Button variant="outline" onClick={archiveCurrentList} disabled={isPending}>
<Archive />
Archive list
</Button>
</header>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
submitItem();
}}
>
<Input
ref={inputRef}
aria-label="Add item"
autoFocus
placeholder={list.type === "shopping" ? "Add milk, eggs, coffee..." : "Add a task..."}
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<Button type="submit" disabled={!draft.trim() || isPending}>
<Plus />
Add
</Button>
</form>
<div className="overflow-hidden rounded-lg border bg-background">
{list.items.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground">Nothing here yet.</div>
) : (
<ul className="divide-y">
{list.items.map((item) => (
<li
key={item.id}
className="grid grid-cols-[auto_1fr_auto] items-center gap-3 p-3"
onPointerDown={(event) => {
swipeStart.current[item.id] = event.clientX;
}}
onPointerUp={(event) => {
const start = swipeStart.current[item.id];
if (start !== undefined && event.clientX - start < -60) removeItem(item);
delete swipeStart.current[item.id];
}}
>
<input
aria-label={`Complete ${item.text}`}
type="checkbox"
className="size-5 accent-primary"
checked={item.done}
onChange={(event) => setItemDone(item, event.target.checked)}
/>
<Input
aria-label={`${item.text} text`}
className={item.done ? "text-muted-foreground line-through" : ""}
value={item.text}
onChange={(event) => editItemText(item, event.target.value)}
onBlur={() => commitItemText(item)}
/>
<div className="flex items-center gap-1">
<GripVertical className="size-4 text-muted-foreground" />
<Button
size="icon-sm"
variant="ghost"
aria-label={`Delete ${item.text}`}
onClick={() => removeItem(item)}
>
<Trash2 />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}
@@ -0,0 +1,101 @@
"use client";
import Link from "next/link";
import { 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";
export function ListsIndex({ lists }: { lists: ListDto[] }) {
const [listRows, setListRows] = useState(lists);
const [type, setType] = useState("shopping");
const [name, setName] = useState("");
const [, startTransition] = useTransition();
const grouped = useMemo(() => {
const groups = new Map<string, ListDto[]>();
for (const list of listRows) {
groups.set(list.type, [...(groups.get(list.type) ?? []), list]);
}
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [listRows]);
function addList() {
startTransition(async () => {
const created = await createList({ type, name });
setListRows((current) => [
...current,
{
id: created.id,
type: created.type,
name: created.name,
archived: created.archived,
openCount: 0,
doneCount: 0,
createdAt: created.createdAt.toISOString(),
},
]);
setName("");
});
}
return (
<div className="mx-auto grid w-full max-w-5xl gap-6 p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold">Lists</h1>
<p className="text-sm text-muted-foreground">Shopping, tasks, and whatever comes next.</p>
</div>
<div className="grid gap-2 rounded-lg border bg-background p-3 sm:grid-cols-[140px_220px_auto]">
<div className="space-y-1">
<Label htmlFor="new-list-type">Type</Label>
<Input
id="new-list-type"
value={type}
onChange={(event) => setType(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="new-list-name">Name</Label>
<Input
id="new-list-name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<Button className="self-end" onClick={addList} disabled={!type || !name}>
<Plus />
New list
</Button>
</div>
</div>
<div className="grid gap-6">
{grouped.map(([groupType, groupLists]) => (
<section key={groupType} className="grid gap-3">
<h2 className="text-sm font-medium uppercase tracking-normal text-muted-foreground">
{groupType}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{groupLists.map((list) => (
<Link
key={list.id}
href={`/lists/${list.id}`}
className="rounded-lg border bg-card p-4 text-card-foreground transition-colors hover:bg-muted"
>
<div className="font-medium">{list.name}</div>
<div className="mt-2 text-sm text-muted-foreground">
{list.openCount} open / {list.doneCount} done
</div>
</Link>
))}
</div>
</section>
))}
</div>
</div>
);
}
-23
View File
@@ -1,23 +0,0 @@
import type { ModuleManifest } from "../_core/module";
const manifest: ModuleManifest = {
id: "lists",
name: "Lists",
nav: { href: "/lists", label: "Lists", icon: "list" },
entities: [
{
type: "lists.list",
label: { singular: "List", plural: "Lists" },
resolveUrl: (id) => `/lists/${id}`,
},
{
type: "lists.item",
label: { singular: "List item", plural: "List items" },
resolveUrl: (id) => `/lists/items/${id}`,
},
],
dashboardWidgets: [],
quickAdds: [],
};
export default manifest;
+92
View File
@@ -0,0 +1,92 @@
import type { ModuleManifest } from "../_core/module";
import { z } from "zod";
import { addItemToDefaultList } from "./server/actions";
import { listLists, searchItems, searchLists } from "./server/queries";
const listIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]);
const manifest: ModuleManifest = {
id: "lists",
name: "Lists",
nav: { href: "/lists", label: "Lists", icon: "list" },
entities: [
{
type: "lists.list",
label: { singular: "List", plural: "Lists" },
share: { canShare: true, defaultCapabilities: ["read", "write"] },
search: { search: searchLists },
resolveUrl: (id) => `/lists/${id}`,
},
{
type: "lists.item",
label: { singular: "List item", plural: "List items" },
share: { canShare: false },
search: { search: searchItems },
resolveUrl: (id) => `/lists/items/${id}`,
},
],
dashboardWidgets: [
{
id: "lists.list",
title: "List items",
description: "Open or completed items from selected lists.",
category: "Lists",
defaultSize: { w: 4, h: 3 },
minSize: { w: 3, h: 2 },
defaultPriority: 30,
configSchema: z.object({
listIds: listIdsSchema,
showCompleted: z.boolean(),
limit: z.number().int().min(1).max(50).optional(),
}),
defaultConfig: { listIds: "all", showCompleted: false },
resolveConfigOptions: async () => ({
lists: (await listLists()).map((list) => ({
id: list.id,
type: list.type,
name: list.name,
})),
}),
render: ({ config }) => {
const parsed = z
.object({
listIds: listIdsSchema,
showCompleted: z.boolean(),
limit: z.number().int().min(1).max(50).optional(),
})
.parse(config);
return (
<div className="text-sm text-muted-foreground">
{parsed.showCompleted ? "List items" : "Open list items"}
</div>
);
},
},
],
quickAdds: [
{
id: "lists.add-shopping",
label: "Add to shopping",
icon: "shopping-cart",
action: async () => {
await addItemToDefaultList({ type: "shopping", text: "New item" });
},
},
{
id: "lists.add-task",
label: "Add to tasks",
icon: "list-checks",
action: async () => {
await addItemToDefaultList({ type: "task", text: "New task" });
},
},
{
id: "lists.new-list",
label: "New list",
icon: "list-plus",
action: () => undefined,
},
],
};
export default manifest;
+46
View File
@@ -0,0 +1,46 @@
import { index, integer, pgTable, text, timestamp, uuid, boolean } from "drizzle-orm/pg-core";
import { households, users } from "../_core/schema";
export const lists = pgTable(
"lists",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
type: text("type").notNull(),
name: text("name").notNull(),
archived: boolean("archived").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("lists_household_type_idx").on(t.householdId, t.type),
index("lists_household_archived_idx").on(t.householdId, t.archived),
],
);
export const listItems = pgTable(
"list_items",
{
id: uuid("id").primaryKey().defaultRandom(),
listId: uuid("list_id")
.notNull()
.references(() => lists.id, { onDelete: "cascade" }),
text: text("text").notNull(),
done: boolean("done").notNull().default(false),
qty: text("qty"),
notes: text("notes"),
dueAt: timestamp("due_at", { withTimezone: true }),
assigneeId: uuid("assignee_id").references(() => users.id, { onDelete: "set null" }),
position: integer("position").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("list_items_list_position_idx").on(t.listId, t.position),
index("list_items_assignee_idx").on(t.assigneeId),
],
);
export type List = typeof lists.$inferSelect;
export type ListItem = typeof listItems.$inferSelect;
+201
View File
@@ -0,0 +1,201 @@
"use server";
import { and, eq, max } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { listItems, lists } from "../schema";
import { getOrCreateDefaultList } from "./defaults";
import { canAccessList, getList } from "./queries";
import { notifyListChanged } from "./realtime";
const listInput = z.object({
type: z.string().trim().min(1).max(80),
name: z.string().trim().min(1).max(120),
});
const itemInput = z.object({
listId: z.string().uuid(),
text: z.string().trim().min(1).max(300),
qty: z.string().trim().max(80).nullable().optional(),
notes: z.string().trim().max(2000).nullable().optional(),
dueAt: z.coerce.date().nullable().optional(),
assigneeId: z.string().uuid().nullable().optional(),
});
const updateItemInput = z.object({
id: z.string().uuid(),
text: z.string().trim().min(1).max(300).optional(),
qty: z.string().trim().max(80).nullable().optional(),
notes: z.string().trim().max(2000).nullable().optional(),
dueAt: z.coerce.date().nullable().optional(),
assigneeId: z.string().uuid().nullable().optional(),
});
export async function createList(input: z.input<typeof listInput>) {
const parsed = listInput.parse(input);
const { household } = await getCurrentSession();
const [list] = await db
.insert(lists)
.values({
householdId: household.id,
type: parsed.type,
name: parsed.name,
})
.returning();
if (!list) throw new Error("List was not created");
revalidatePath("/lists");
return list;
}
export async function renameList(input: { id: string; name: string }) {
const parsed = z.object({ id: z.string().uuid(), name: listInput.shape.name }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessList(parsed.id, household.id);
await db.update(lists).set({ name: parsed.name }).where(eq(lists.id, parsed.id));
revalidatePath("/lists");
revalidatePath(`/lists/${parsed.id}`);
await notifyListChanged(parsed.id);
}
export async function archiveList(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessList(parsed.id, household.id);
await db.update(lists).set({ archived: true }).where(eq(lists.id, parsed.id));
revalidatePath("/lists");
revalidatePath(`/lists/${parsed.id}`);
await notifyListChanged(parsed.id);
}
export async function addItem(input: z.input<typeof itemInput>) {
const parsed = itemInput.parse(input);
const { household } = await getCurrentSession();
await assertCanAccessList(parsed.listId, household.id);
const [positionRow] = await db
.select({ maxPosition: max(listItems.position) })
.from(listItems)
.where(eq(listItems.listId, parsed.listId));
const [item] = await db
.insert(listItems)
.values({
listId: parsed.listId,
text: parsed.text,
qty: parsed.qty || null,
notes: parsed.notes || null,
dueAt: parsed.dueAt ?? null,
assigneeId: parsed.assigneeId ?? null,
position: (positionRow?.maxPosition ?? -1) + 1,
})
.returning();
if (!item) throw new Error("List item was not created");
revalidatePath(`/lists/${parsed.listId}`);
await notifyListChanged(parsed.listId);
return getList(parsed.listId);
}
export async function addItemToDefaultList(input: { type: string; text: string }) {
const parsed = z
.object({
type: listInput.shape.type,
text: itemInput.shape.text,
})
.parse(input);
const { household } = await getCurrentSession();
const list = await getOrCreateDefaultList({ householdId: household.id, type: parsed.type });
return addItem({ listId: list.id, text: parsed.text });
}
export async function toggleItem(input: { id: string; done?: boolean }) {
const parsed = z.object({ id: z.string().uuid(), done: z.boolean().optional() }).parse(input);
const { household } = await getCurrentSession();
const existing = await getAuthorizedItem(parsed.id, household.id);
const done = parsed.done ?? !existing.done;
await db
.update(listItems)
.set({ done, updatedAt: new Date() })
.where(eq(listItems.id, parsed.id));
revalidatePath(`/lists/${existing.listId}`);
await notifyListChanged(existing.listId);
return getList(existing.listId);
}
export async function updateItem(input: z.input<typeof updateItemInput>) {
const parsed = updateItemInput.parse(input);
const { household } = await getCurrentSession();
const existing = await getAuthorizedItem(parsed.id, household.id);
await db
.update(listItems)
.set({
text: parsed.text,
qty: parsed.qty === undefined ? undefined : parsed.qty || null,
notes: parsed.notes === undefined ? undefined : parsed.notes || null,
dueAt: parsed.dueAt === undefined ? undefined : parsed.dueAt,
assigneeId: parsed.assigneeId === undefined ? undefined : parsed.assigneeId,
updatedAt: new Date(),
})
.where(eq(listItems.id, parsed.id));
revalidatePath(`/lists/${existing.listId}`);
await notifyListChanged(existing.listId);
return getList(existing.listId);
}
export async function deleteItem(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
const existing = await getAuthorizedItem(parsed.id, household.id);
await db.delete(listItems).where(eq(listItems.id, parsed.id));
revalidatePath(`/lists/${existing.listId}`);
await notifyListChanged(existing.listId);
return getList(existing.listId);
}
export async function reorderItems(input: { listId: string; itemIds: string[] }) {
const parsed = z
.object({ listId: z.string().uuid(), itemIds: z.array(z.string().uuid()) })
.parse(input);
const { household } = await getCurrentSession();
await assertCanAccessList(parsed.listId, household.id);
await db.transaction(async (tx) => {
for (const [position, id] of parsed.itemIds.entries()) {
await tx
.update(listItems)
.set({ position, updatedAt: new Date() })
.where(and(eq(listItems.id, id), eq(listItems.listId, parsed.listId)));
}
});
revalidatePath(`/lists/${parsed.listId}`);
await notifyListChanged(parsed.listId);
return getList(parsed.listId);
}
async function assertCanAccessList(listId: string, householdId: string) {
if (!(await canAccessList(listId, householdId))) throw new Error("Forbidden");
}
async function getAuthorizedItem(itemId: string, householdId: string) {
const [item] = await db
.select({
id: listItems.id,
listId: listItems.listId,
done: listItems.done,
})
.from(listItems)
.innerJoin(lists, eq(listItems.listId, lists.id))
.where(and(eq(listItems.id, itemId), eq(lists.householdId, householdId)))
.limit(1);
if (!item) throw new Error("Item not found");
return item;
}
+65
View File
@@ -0,0 +1,65 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { households } from "@/modules/_core/schema";
import { lists } from "../schema";
const DEFAULT_LISTS = [
{ type: "shopping", name: "Shopping" },
{ type: "task", name: "Tasks" },
] as const;
export async function ensureDefaultListsForHousehold(householdId: string) {
for (const defaults of DEFAULT_LISTS) {
await ensureDefaultList({ householdId, ...defaults });
}
}
export async function ensureDefaultLists() {
const householdRows = await db.select({ id: households.id }).from(households);
for (const household of householdRows) {
await ensureDefaultListsForHousehold(household.id);
}
}
export async function getOrCreateDefaultList({
householdId,
type,
}: {
householdId: string;
type: "shopping" | "task" | string;
}) {
const defaults = DEFAULT_LISTS.find((list) => list.type === type);
const name = defaults?.name ?? type;
return ensureDefaultList({ householdId, type, name });
}
async function ensureDefaultList({
householdId,
type,
name,
}: {
householdId: string;
type: string;
name: string;
}) {
const [existing] = await db
.select()
.from(lists)
.where(and(eq(lists.householdId, householdId), eq(lists.type, type), eq(lists.name, name)))
.limit(1);
if (existing) return existing;
const [created] = await db
.insert(lists)
.values({
householdId,
type,
name,
})
.returning();
if (!created) throw new Error("Default list was not created");
return created;
}
+256
View File
@@ -0,0 +1,256 @@
"use server";
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { listItems, lists } from "../schema";
import { ensureDefaultListsForHousehold } from "./defaults";
export type ListDto = {
id: string;
type: string;
name: string;
archived: boolean;
openCount: number;
doneCount: number;
createdAt: string;
};
export type ListItemDto = {
id: string;
listId: string;
text: string;
done: boolean;
qty: string | null;
notes: string | null;
dueAt: string | null;
assigneeId: string | null;
position: number;
createdAt: string;
updatedAt: string;
};
export type ListDetailDto = ListDto & {
items: ListItemDto[];
};
const listListsInput = z
.object({
type: z.string().trim().min(1).max(80).optional(),
includeArchived: z.boolean().default(false),
})
.optional();
export async function listLists(input?: z.input<typeof listListsInput>): Promise<ListDto[]> {
const parsed = listListsInput.parse(input) ?? { includeArchived: false };
const { household } = await getCurrentSession();
await ensureDefaultListsForHousehold(household.id);
const conditions = [eq(lists.householdId, household.id)];
if (parsed.type) conditions.push(eq(lists.type, parsed.type));
if (!parsed.includeArchived) conditions.push(eq(lists.archived, false));
const rows = await db
.select({
id: lists.id,
type: lists.type,
name: lists.name,
archived: lists.archived,
createdAt: lists.createdAt,
done: listItems.done,
})
.from(lists)
.leftJoin(listItems, eq(listItems.listId, lists.id))
.where(and(...conditions))
.orderBy(asc(lists.type), asc(lists.name), asc(lists.createdAt));
return summarizeLists(rows);
}
export async function getList(id: string): Promise<ListDetailDto> {
const parsed = z.string().uuid().parse(id);
const { household } = await getCurrentSession();
await ensureDefaultListsForHousehold(household.id);
const [list] = await db
.select()
.from(lists)
.where(and(eq(lists.id, parsed), eq(lists.householdId, household.id)))
.limit(1);
if (!list) throw new Error("List not found");
const items = await db
.select()
.from(listItems)
.where(eq(listItems.listId, list.id))
.orderBy(asc(listItems.done), asc(listItems.position), asc(listItems.createdAt));
const dtoItems = items.map(toItemDto);
return {
...toListDto(list, dtoItems),
items: dtoItems,
};
}
export async function canAccessList(listId: string, householdId: string) {
const [list] = await db
.select({ id: lists.id })
.from(lists)
.where(and(eq(lists.id, listId), eq(lists.householdId, householdId)))
.limit(1);
return !!list;
}
export async function searchLists(query: string, householdId: string) {
const rows = await db
.select({ id: lists.id, name: lists.name, type: lists.type })
.from(lists)
.where(
and(
eq(lists.householdId, householdId),
eq(lists.archived, false),
or(sql`${lists.name} ilike ${`%${query}%`}`, sql`${lists.type} ilike ${`%${query}%`}`),
),
)
.limit(10);
return rows.map((row) => ({
id: row.id,
title: row.name,
url: `/lists/${row.id}`,
excerpt: row.type,
}));
}
export async function searchItems(query: string, householdId: string) {
const rows = await db
.select({
id: listItems.id,
listId: listItems.listId,
text: listItems.text,
notes: listItems.notes,
listName: lists.name,
})
.from(listItems)
.innerJoin(lists, eq(listItems.listId, lists.id))
.where(
and(
eq(lists.householdId, householdId),
eq(lists.archived, false),
or(
sql`${listItems.text} ilike ${`%${query}%`}`,
sql`${listItems.notes} ilike ${`%${query}%`}`,
),
),
)
.limit(10);
return rows.map((row) => ({
id: row.id,
title: row.text,
url: `/lists/${row.listId}`,
excerpt: row.notes ?? row.listName,
}));
}
export async function listWidgetItems(input: {
listIds: "all" | string[];
showCompleted: boolean;
limit?: number;
}) {
const parsed = z
.object({
listIds: z.union([z.literal("all"), z.array(z.string().uuid())]),
showCompleted: z.boolean(),
limit: z.number().int().min(1).max(50).optional(),
})
.parse(input);
const allLists = await listLists();
const visibleIds =
parsed.listIds === "all"
? allLists.map((list) => list.id)
: parsed.listIds.filter((id) => allLists.some((list) => list.id === id));
if (visibleIds.length === 0) return [];
const conditions = [inArray(listItems.listId, visibleIds)];
if (!parsed.showCompleted) conditions.push(eq(listItems.done, false));
const rows = await db
.select({
id: listItems.id,
text: listItems.text,
done: listItems.done,
listName: lists.name,
})
.from(listItems)
.innerJoin(lists, eq(listItems.listId, lists.id))
.where(and(...conditions))
.orderBy(asc(listItems.done), asc(listItems.position), asc(listItems.createdAt))
.limit(parsed.limit ?? 10);
return rows;
}
function summarizeLists(
rows: {
id: string;
type: string;
name: string;
archived: boolean;
createdAt: Date;
done: boolean | null;
}[],
) {
const byId = new Map<string, ListDto>();
for (const row of rows) {
const existing =
byId.get(row.id) ??
({
id: row.id,
type: row.type,
name: row.name,
archived: row.archived,
openCount: 0,
doneCount: 0,
createdAt: row.createdAt.toISOString(),
} satisfies ListDto);
if (row.done === true) existing.doneCount += 1;
if (row.done === false) existing.openCount += 1;
byId.set(row.id, existing);
}
return [...byId.values()];
}
function toListDto(list: typeof lists.$inferSelect, items: ListItemDto[]): ListDto {
return {
id: list.id,
type: list.type,
name: list.name,
archived: list.archived,
openCount: items.filter((item) => !item.done).length,
doneCount: items.filter((item) => item.done).length,
createdAt: list.createdAt.toISOString(),
};
}
function toItemDto(item: typeof listItems.$inferSelect): ListItemDto {
return {
id: item.id,
listId: item.listId,
text: item.text,
done: item.done,
qty: item.qty,
notes: item.notes,
dueAt: item.dueAt?.toISOString() ?? null,
assigneeId: item.assigneeId,
position: item.position,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
};
}
+12
View File
@@ -0,0 +1,12 @@
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
export function listChannel(listId: string) {
return `list:${listId}`;
}
export async function notifyListChanged(listId: string) {
await db.execute(
sql`select pg_notify(${listChannel(listId)}, ${JSON.stringify({ listId, at: Date.now() })})`,
);
}