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
+91
View File
@@ -0,0 +1,91 @@
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { getCurrentSession } from "@/lib/session";
import { parseDashboardLayout, computeDefaultLayout } from "@/lib/dashboard";
import { getWidget, getWidgetMetas } from "@/modules/_core";
import { getDashboardBySlug } from "@/app/d/actions";
import { DashboardEditor } from "@/components/dashboard-editor";
import { QuickAddFab } from "@/components/quick-add-fab";
import { EditDashboardButton } from "@/components/edit-dashboard-button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const smColSpan: Record<number, string> = {
1: "sm:col-span-1", 2: "sm:col-span-2", 3: "sm:col-span-3",
4: "sm:col-span-4", 5: "sm:col-span-5", 6: "sm:col-span-6",
7: "sm:col-span-7", 8: "sm:col-span-8", 9: "sm:col-span-9",
10: "sm:col-span-10", 11: "sm:col-span-11", 12: "sm:col-span-12",
};
export default async function DashboardPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ edit?: string }>;
}) {
const { slug } = await params;
const { edit } = await searchParams;
const isEditing = edit === "1";
const { user, household } = await getCurrentSession();
const dashboard = await getDashboardBySlug(slug);
if (!dashboard) notFound();
const layout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
const widgetMetas = getWidgetMetas();
if (isEditing) {
return (
<DashboardEditor
dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }}
layout={layout}
widgetMetas={widgetMetas}
/>
);
}
const ctx = { userId: user.id, householdId: household.id };
const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
return (
<div className="p-4 sm:p-6">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">{dashboard.name}</h1>
<div className="flex items-center gap-2">
<QuickAddFab />
<EditDashboardButton />
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12">
{placements.map((placement, i) => {
const widget = getWidget(placement.widgetId);
if (!widget) return null;
const colClass = smColSpan[placement.w] ?? "sm:col-span-12";
return (
<div key={i} className={`col-span-1 ${colClass}`}>
<Card className="h-full">
<CardHeader className="pb-2">
<CardTitle className="text-base">{widget.title}</CardTitle>
</CardHeader>
<CardContent>
<Suspense
fallback={
<div className="animate-pulse space-y-2">
<div className="h-3 w-3/4 rounded bg-muted" />
<div className="h-3 w-1/2 rounded bg-muted" />
<div className="h-3 w-2/3 rounded bg-muted" />
</div>
}
>
{widget.render({ config: placement.config, ctx })}
</Suspense>
</CardContent>
</Card>
</div>
);
})}
</div>
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
"use server";
import { and, asc, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { getWidget } from "@/modules/_core";
import { dashboards } from "@/modules/_core/schema";
import { computeDefaultLayout, type DashboardLayout } from "@/lib/dashboard";
export type DashboardMeta = {
id: string;
name: string;
slug: string;
isDefault: boolean;
position: number;
};
export async function listDashboards(): Promise<DashboardMeta[]> {
const { user } = await getCurrentSession();
const rows = await db
.select({ id: dashboards.id, name: dashboards.name, slug: dashboards.slug, isDefault: dashboards.isDefault, position: dashboards.position })
.from(dashboards)
.where(eq(dashboards.userId, user.id))
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
return rows;
}
export async function getDefaultDashboardSlug(): Promise<string> {
const { user } = await getCurrentSession();
const rows = await db
.select({ slug: dashboards.slug })
.from(dashboards)
.where(and(eq(dashboards.userId, user.id), eq(dashboards.isDefault, true)))
.limit(1);
if (rows[0]) return rows[0].slug;
// Fallback: first dashboard by position
const [first] = await db
.select({ slug: dashboards.slug })
.from(dashboards)
.where(eq(dashboards.userId, user.id))
.orderBy(asc(dashboards.position), asc(dashboards.createdAt))
.limit(1);
if (first) return first.slug;
// No dashboards yet — create Home
await createDashboard("Home");
return "home";
}
export async function getDashboardBySlug(slug: string) {
const { user } = await getCurrentSession();
const [row] = await db
.select()
.from(dashboards)
.where(and(eq(dashboards.userId, user.id), eq(dashboards.slug, slug)))
.limit(1);
return row ?? null;
}
function toSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
|| "dashboard";
}
async function uniqueSlug(userId: string, base: string): Promise<string> {
let slug = base;
let attempt = 1;
for (;;) {
const [existing] = await db
.select({ id: dashboards.id })
.from(dashboards)
.where(and(eq(dashboards.userId, userId), eq(dashboards.slug, slug)))
.limit(1);
if (!existing) return slug;
attempt++;
slug = `${base}-${attempt}`;
}
}
export async function createDashboard(name: string): Promise<DashboardMeta> {
const parsed = z.string().trim().min(1).max(80).parse(name);
const { user } = await getCurrentSession();
const slug = await uniqueSlug(user.id, toSlug(parsed));
const rows = await db
.insert(dashboards)
.values({ userId: user.id, name: parsed, slug, isDefault: false, position: 9999 })
.returning();
const row = rows[0];
if (!row) throw new Error("Insert failed");
revalidatePath("/");
return { id: row.id, name: row.name, slug: row.slug, isDefault: row.isDefault, position: row.position };
}
export async function renameDashboard(id: string, name: string): Promise<void> {
const parsedName = z.string().trim().min(1).max(80).parse(name);
const { user } = await getCurrentSession();
await db
.update(dashboards)
.set({ name: parsedName, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
revalidatePath("/");
}
export async function deleteDashboard(id: string): Promise<void> {
const { user } = await getCurrentSession();
const all = await db
.select({ id: dashboards.id, isDefault: dashboards.isDefault, slug: dashboards.slug })
.from(dashboards)
.where(eq(dashboards.userId, user.id));
if (all.length <= 1) throw new Error("Cannot delete your only dashboard");
const target = all.find((d) => d.id === id);
if (!target) throw new Error("Dashboard not found");
await db.delete(dashboards).where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
// If we deleted the default, promote another
if (target.isDefault) {
const next = all.find((d) => d.id !== id);
if (next) {
await db
.update(dashboards)
.set({ isDefault: true })
.where(and(eq(dashboards.id, next.id), eq(dashboards.userId, user.id)));
}
}
revalidatePath("/");
redirect("/");
}
export async function setDefaultDashboard(id: string): Promise<void> {
const { user } = await getCurrentSession();
await db
.update(dashboards)
.set({ isDefault: false })
.where(eq(dashboards.userId, user.id));
await db
.update(dashboards)
.set({ isDefault: true })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
revalidatePath("/");
}
export async function reorderDashboards(orderedIds: string[]): Promise<void> {
const { user } = await getCurrentSession();
await Promise.all(
orderedIds.map((id, i) =>
db
.update(dashboards)
.set({ position: i })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))),
),
);
revalidatePath("/");
}
export async function saveDashboardLayout(id: string, layout: DashboardLayout): Promise<void> {
const { user } = await getCurrentSession();
// Validate each widget's config against its registered schema
for (const placement of layout.widgets) {
const widget = getWidget(placement.widgetId);
if (!widget) continue;
widget.configSchema.parse(placement.config);
}
await db
.update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
revalidatePath("/");
}
export async function resetDashboardLayout(id: string): Promise<void> {
const { user } = await getCurrentSession();
const layout = computeDefaultLayout();
await db
.update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
revalidatePath("/");
}
export async function resolveWidgetConfigOptions(widgetId: string): Promise<unknown> {
const { user, household } = await getCurrentSession();
const widget = getWidget(widgetId);
if (!widget?.resolveConfigOptions) return null;
return widget.resolveConfigOptions({ userId: user.id, householdId: household.id });
}
+10 -3
View File
@@ -6,8 +6,9 @@ import { AppNav } from "@/components/app-nav";
import "@/modules"; // registers all module manifests
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { eq } from "drizzle-orm";
import { users } from "@/modules/_core/schema";
import { asc, eq } from "drizzle-orm";
import { dashboards, users } from "@/modules/_core/schema";
import type { DashboardMeta } from "@/app/d/actions";
import { getQuickAdds } from "@/modules/_core";
import { QuickAddProvider } from "@/components/quick-add-provider";
import { QuickAddSheet } from "@/components/quick-add-sheet";
@@ -40,6 +41,7 @@ export default async function RootLayout({
}) {
let theme = "default";
let themeMode = "system";
let userDashboards: DashboardMeta[] = [];
const session = await auth();
if (session?.user?.id) {
@@ -52,6 +54,11 @@ export default async function RootLayout({
theme = row.theme;
themeMode = row.themeMode;
}
userDashboards = await db
.select({ id: dashboards.id, name: dashboards.name, slug: dashboards.slug, isDefault: dashboards.isDefault, position: dashboards.position })
.from(dashboards)
.where(eq(dashboards.userId, session.user.id))
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
}
// For system mode we can't know the preference on the server — the inline
@@ -71,7 +78,7 @@ export default async function RootLayout({
</head>
<body className="min-h-screen">
<QuickAddProvider actions={quickAdds}>
<AppNav />
<AppNav dashboards={userDashboards} />
<main>{children}</main>
<QuickAddSheet />
<CommandPalette />
+5 -81
View File
@@ -1,83 +1,7 @@
import { Suspense } from "react";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { computeDefaultLayout, parseDashboardLayout } from "@/lib/dashboard";
import { getCurrentSession } from "@/lib/session";
import { getWidget } from "@/modules/_core";
import { users } from "@/modules/_core/schema";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { QuickAddFab } from "@/components/quick-add-fab";
import { redirect } from "next/navigation";
import { getDefaultDashboardSlug } from "@/app/d/actions";
// Static lookup ensures Tailwind sees all sm:col-span-* classes at build time.
const smColSpan: Record<number, string> = {
1: "sm:col-span-1",
2: "sm:col-span-2",
3: "sm:col-span-3",
4: "sm:col-span-4",
5: "sm:col-span-5",
6: "sm:col-span-6",
7: "sm:col-span-7",
8: "sm:col-span-8",
9: "sm:col-span-9",
10: "sm:col-span-10",
11: "sm:col-span-11",
12: "sm:col-span-12",
};
export default async function DashboardPage() {
const { user, household } = await getCurrentSession();
const [row] = await db
.select({ layout: users.defaultDashboardLayout })
.from(users)
.where(eq(users.id, user.id))
.limit(1);
const layout = parseDashboardLayout(row?.layout) ?? computeDefaultLayout();
const ctx = { userId: user.id, householdId: household.id };
// Sort by y then x so document order matches visual order on mobile (single col).
const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
return (
<div className="p-4 sm:p-6">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1>
<QuickAddFab />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12">
{placements.map((placement, i) => {
const widget = getWidget(placement.widgetId);
if (!widget) return null;
const colClass = smColSpan[placement.w] ?? "sm:col-span-12";
return (
<div key={i} className={`col-span-1 ${colClass}`}>
<Card className="h-full">
<CardHeader className="pb-2">
<CardTitle className="text-base">{widget.title}</CardTitle>
</CardHeader>
<CardContent>
<Suspense
fallback={
<div className="animate-pulse space-y-2">
<div className="h-3 w-3/4 rounded bg-muted" />
<div className="h-3 w-1/2 rounded bg-muted" />
<div className="h-3 w-2/3 rounded bg-muted" />
</div>
}
>
{widget.render({ config: placement.config, ctx })}
</Suspense>
</CardContent>
</Card>
</div>
);
})}
</div>
</div>
);
export default async function RootPage() {
const slug = await getDefaultDashboardSlug();
redirect(`/d/${slug}`);
}
+12
View File
@@ -26,6 +26,18 @@ export async function setUserTheme({
.where(eq(users.id, user.id));
}
export async function setCompletionVisibilityHours(hours: number): Promise<void> {
const parsed = Number(hours);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 8760)
throw new Error("Invalid hours value");
const { user } = await getCurrentSession();
await db
.update(users)
.set({ completionVisibilityHours: parsed })
.where(eq(users.id, user.id));
revalidatePath("/settings");
}
export async function revokeShareLinkAction(formData: FormData): Promise<void> {
const id = formData.get("id");
if (typeof id !== "string") throw new Error("Missing id");
+1 -1
View File
@@ -34,7 +34,7 @@ export default async function SettingsPage() {
<CardTitle>Lists</CardTitle>
</CardHeader>
<CardContent>
<CompletionDelaySetting />
<CompletionDelaySetting initialHours={user.completionVisibilityHours} />
</CardContent>
</Card>