Implement tasks 25, 26 + completion visibility setting

Task 25: Multiple dashboards — dashboards table + migration, /d/[slug]
route, root redirect, dashboard tabs in nav with create/rename/delete/
set-default. Task 26: Editable dashboard — react-grid-layout drag/resize
editor, widget picker modal with per-widget configurator auto-generated
from default config, save/reset server actions with Zod validation.

Completion visibility: server-side per-user setting (hours) replaces
localStorage timer; queries filter completed items by updatedAt cutoff;
Settings page dropdown persists preference via server action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ginnoir
2026-05-06 15:52:59 -05:00
co-authored by Claude Sonnet 4.6
parent 2ad9521ef9
commit d5a8bf9d95
27 changed files with 1646 additions and 237 deletions
+2 -2
View File
@@ -10,8 +10,8 @@ export type {
SearchResult,
ActivityLogEntry,
} from "./module";
export { registerModule, getRegistry, getEntityType, getWidget, getQuickAdds } from "./registry";
export type { QuickAddItem, SerializedQuickAddItem } from "./registry";
export { registerModule, getRegistry, getEntityType, getWidget, getQuickAdds, getWidgetMetas } from "./registry";
export type { QuickAddItem, SerializedQuickAddItem, SerializedWidgetMeta } from "./registry";
export { logActivity, logShareActivity } from "./activity";
export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
+17
View File
@@ -42,6 +42,23 @@ export type SerializedQuickAddItem = {
moduleName: string;
};
export type SerializedWidgetMeta = {
id: string;
title: string;
description: string;
category?: string;
defaultSize: { w: number; h: number };
minSize?: { w: number; h: number };
maxSize?: { w: number; h: number };
defaultConfig: unknown;
};
export function getWidgetMetas(): SerializedWidgetMeta[] {
return [...widgets.values()].map(({ id, title, description, category, defaultSize, minSize, maxSize, defaultConfig }) => ({
id, title, description, category, defaultSize, minSize, maxSize, defaultConfig,
}));
}
export function getQuickAdds(): SerializedQuickAddItem[] {
return [...modules.values()].flatMap((manifest) =>
(manifest.quickAdds ?? []).map(({ id, label, icon, url }) => ({
+20 -1
View File
@@ -1,5 +1,6 @@
import type { AdapterAccountType } from "@auth/core/adapters";
import {
boolean,
index,
integer,
jsonb,
@@ -24,7 +25,7 @@ export const users = pgTable("users", {
image: text("image"),
theme: text("theme").notNull().default("default"),
themeMode: text("theme_mode").notNull().default("system"),
defaultDashboardLayout: jsonb("default_dashboard_layout"),
completionVisibilityHours: integer("completion_visibility_hours").notNull().default(24),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
@@ -129,6 +130,24 @@ export const shareLinks = pgTable(
],
);
export const dashboards = pgTable(
"dashboards",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
slug: text("slug").notNull(),
isDefault: boolean("is_default").notNull().default(false),
position: integer("position").notNull().default(0),
layout: jsonb("layout").notNull().default({ version: 1, widgets: [] }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [uniqueIndex("dashboards_user_slug_uq").on(t.userId, t.slug)],
);
export const reminders = pgTable(
"reminders",
{
+2 -25
View File
@@ -1,8 +1,7 @@
"use client";
import Link from "next/link";
import { useRef, useState, useTransition } from "react";
import { useCompletionDelay } from "@/hooks/use-completion-delay";
import { useState, useTransition } from "react";
import { toggleItem } from "../server/actions";
type WidgetItem = {
@@ -16,31 +15,9 @@ type WidgetItem = {
export function ListWidget({ initialItems }: { initialItems: WidgetItem[] }) {
const [items, setItems] = useState<WidgetItem[]>(initialItems);
const [, startTransition] = useTransition();
const { delay } = useCompletionDelay();
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
function toggle(item: WidgetItem, done: boolean) {
if (done) {
setItems((current) =>
current.map((i) => (i.id === item.id ? { ...i, done: true } : i)),
);
const t = setTimeout(() => {
setItems((current) => current.filter((i) => i.id !== item.id));
timers.current.delete(item.id);
}, delay);
timers.current.set(item.id, t);
} else {
const existing = timers.current.get(item.id);
if (existing) {
clearTimeout(existing);
timers.current.delete(item.id);
}
setItems((current) =>
current.map((i) => (i.id === item.id ? { ...i, done: false } : i)),
);
}
setItems((current) => current.map((i) => (i.id === item.id ? { ...i, done } : i)));
startTransition(async () => {
await toggleItem({ id: item.id, done });
});
+13 -53
View File
@@ -2,11 +2,10 @@
import Link from "next/link";
import { ChevronDown, ChevronRight, ExternalLink, Plus } from "lucide-react";
import { useMemo, useRef, useState, useTransition } from "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 { useCompletionDelay } from "@/hooks/use-completion-delay";
import type { ListIndexItem, ListWithItemsDto } from "../server/queries";
import { createList, toggleItem } from "../server/actions";
@@ -15,8 +14,6 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
const [type, setType] = useState("shopping");
const [name, setName] = useState("");
const [, startTransition] = useTransition();
const { delay } = useCompletionDelay();
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const grouped = useMemo(() => {
const groups = new Map<string, ListWithItemsDto[]>();
@@ -47,55 +44,18 @@ export function ListsIndex({ lists }: { lists: ListWithItemsDto[] }) {
}
function handleToggle(listId: string, item: ListIndexItem, done: boolean) {
const timerId = `${listId}:${item.id}`;
if (done) {
// Mark done immediately (strikethrough) and schedule removal
setListRows((current) =>
current.map((list) =>
list.id !== listId
? list
: {
...list,
openCount: list.openCount - 1,
doneCount: list.doneCount + 1,
items: list.items.map((i) => (i.id === item.id ? { ...i, done: true } : i)),
},
),
);
const t = setTimeout(() => {
setListRows((current) =>
current.map((list) =>
list.id !== listId
? list
: { ...list, items: list.items.filter((i) => i.id !== item.id) },
),
);
timers.current.delete(timerId);
}, delay);
timers.current.set(timerId, t);
} else {
// Cancel pending removal and restore item
const existing = timers.current.get(timerId);
if (existing) {
clearTimeout(existing);
timers.current.delete(timerId);
}
setListRows((current) =>
current.map((list) =>
list.id !== listId
? list
: {
...list,
openCount: list.openCount + 1,
doneCount: list.doneCount - 1,
items: list.items.map((i) => (i.id === item.id ? { ...i, done: false } : i)),
},
),
);
}
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.map((i) => (i.id === item.id ? { ...i, done } : i)),
},
),
);
startTransition(async () => {
await toggleItem({ id: item.id, done });
});
+21 -8
View File
@@ -1,6 +1,6 @@
"use server";
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
import { and, asc, eq, gt, inArray, or, sql } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
@@ -166,7 +166,7 @@ export type ListIndexItem = {
export type ListWithItemsDto = ListDto & { items: ListIndexItem[] };
export async function listListsWithItems(): Promise<ListWithItemsDto[]> {
const { household } = await getCurrentSession();
const { user, household } = await getCurrentSession();
await ensureDefaultListsForHousehold(household.id);
const listsRows = await db
@@ -178,8 +178,8 @@ export async function listListsWithItems(): Promise<ListWithItemsDto[]> {
if (listsRows.length === 0) return [];
const listIds = listsRows.map((l) => l.id);
const cutoff = completionCutoff(user.completionVisibilityHours);
// 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,
@@ -190,8 +190,13 @@ export async function listListsWithItems(): Promise<ListWithItemsDto[]> {
createdAt: listItems.createdAt,
})
.from(listItems)
.where(and(inArray(listItems.listId, listIds), eq(listItems.done, false)))
.orderBy(asc(listItems.position), asc(listItems.createdAt));
.where(
and(
inArray(listItems.listId, listIds),
or(eq(listItems.done, false), and(eq(listItems.done, true), gt(listItems.updatedAt, cutoff))),
),
)
.orderBy(asc(listItems.done), asc(listItems.position), asc(listItems.createdAt));
const itemsByList = new Map<string, ListIndexItem[]>();
for (const item of itemRows) {
@@ -254,8 +259,12 @@ export async function listWidgetItems(input: {
if (visibleIds.length === 0) return [];
const conditions = [inArray(listItems.listId, visibleIds)];
if (!parsed.showCompleted) conditions.push(eq(listItems.done, false));
const { user } = await getCurrentSession();
const cutoff = completionCutoff(user.completionVisibilityHours);
const doneFilter = parsed.showCompleted
? undefined
: or(eq(listItems.done, false), and(eq(listItems.done, true), gt(listItems.updatedAt, cutoff)));
const rows = await db
.select({
@@ -267,7 +276,7 @@ export async function listWidgetItems(input: {
})
.from(listItems)
.innerJoin(lists, eq(listItems.listId, lists.id))
.where(and(...conditions))
.where(and(inArray(listItems.listId, visibleIds), doneFilter))
.orderBy(asc(listItems.done), asc(listItems.position), asc(listItems.createdAt))
.limit(parsed.limit ?? 10);
@@ -318,6 +327,10 @@ function toListDto(list: typeof lists.$inferSelect, items: ListItemDto[]): ListD
};
}
function completionCutoff(hours: number): Date {
return new Date(Date.now() - hours * 60 * 60 * 1000);
}
function toItemDto(item: typeof listItems.$inferSelect): ListItemDto {
return {
id: item.id,