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:
co-authored by
Claude Sonnet 4.6
parent
2ad9521ef9
commit
d5a8bf9d95
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user