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
+203
View File
@@ -0,0 +1,203 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { MoreHorizontal, Plus, Star, Trash2, PenLine } from "lucide-react";
import {
createDashboard,
deleteDashboard,
renameDashboard,
setDefaultDashboard,
} from "@/app/d/actions";
import type { DashboardMeta } from "@/app/d/actions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function DashboardSwitcher({ dashboards }: { dashboards: DashboardMeta[] }) {
const pathname = usePathname();
const router = useRouter();
const [, startTransition] = useTransition();
const [creatingNew, setCreatingNew] = useState(false);
const [newName, setNewName] = useState("");
function activeSlug() {
const m = pathname.match(/^\/d\/([^/]+)/);
return m?.[1] ?? null;
}
function handleCreate() {
if (!newName.trim()) return;
startTransition(async () => {
const created = await createDashboard(newName.trim());
setCreatingNew(false);
setNewName("");
router.push(`/d/${created.slug}`);
});
}
return (
<div className="flex items-center gap-1">
{/* Active tab highlights — overlay on top of the server-rendered links */}
{dashboards.map((d) => {
const isActive = activeSlug() === d.slug;
return (
<span
key={d.id}
aria-hidden
className={`absolute pointer-events-none border-b-2 transition-colors ${
isActive ? "border-primary" : "border-transparent"
}`}
style={{ display: "none" }}
/>
);
})}
{creatingNew ? (
<form
onSubmit={(e) => {
e.preventDefault();
handleCreate();
}}
className="flex items-center gap-1 ml-1"
>
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Dashboard name"
className="h-7 rounded border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onKeyDown={(e) => e.key === "Escape" && setCreatingNew(false)}
/>
<button type="submit" className="text-xs text-muted-foreground hover:text-foreground px-1">
Add
</button>
<button
type="button"
onClick={() => setCreatingNew(false)}
className="text-xs text-muted-foreground hover:text-foreground px-1"
>
Cancel
</button>
</form>
) : (
<button
type="button"
onClick={() => setCreatingNew(true)}
className="shrink-0 flex items-center gap-1 px-2 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
aria-label="New dashboard"
>
<Plus className="size-3.5" />
</button>
)}
{dashboards.map((d) => {
const isActive = activeSlug() === d.slug;
if (!isActive) return null;
return (
<DashboardKebab
key={d.id}
dashboard={d}
canDelete={dashboards.length > 1}
/>
);
})}
</div>
);
}
function DashboardKebab({
dashboard,
canDelete,
}: {
dashboard: DashboardMeta;
canDelete: boolean;
}) {
const [, startTransition] = useTransition();
const [renaming, setRenaming] = useState(false);
const [newName, setNewName] = useState(dashboard.name);
function handleRename() {
if (!newName.trim() || newName === dashboard.name) {
setRenaming(false);
return;
}
startTransition(async () => {
await renameDashboard(dashboard.id, newName.trim());
setRenaming(false);
});
}
if (renaming) {
return (
<form
onSubmit={(e) => {
e.preventDefault();
handleRename();
}}
className="flex items-center gap-1 ml-1"
>
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="h-7 rounded border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onKeyDown={(e) => e.key === "Escape" && setRenaming(false)}
/>
<button type="submit" className="text-xs text-muted-foreground hover:text-foreground px-1">
Save
</button>
<button
type="button"
onClick={() => setRenaming(false)}
className="text-xs text-muted-foreground hover:text-foreground px-1"
>
Cancel
</button>
</form>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger
className="shrink-0 flex items-center px-1 py-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Dashboard options"
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setRenaming(true)}>
<PenLine className="size-4 mr-2" />
Rename
</DropdownMenuItem>
{!dashboard.isDefault && (
<DropdownMenuItem
onSelect={() =>
startTransition(() => setDefaultDashboard(dashboard.id))
}
>
<Star className="size-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
>
<Trash2 className="size-4 mr-2" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}