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
+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 });
}