feat(web): wire workspace-home dashboard to real tRPC queries

Path-A task 2/5. Replaces the hardcoded `stats` (24/8/12) and
hardcoded `recent` list on the workspace-home page with real
workspace-scoped data.

* server/routers/objects.ts: add two new procedures.
  - `objects.stats` returns { openTasks, containers }. Open-task count
    treats null status as open; only `done` and `closed` (per
    packages/shared object-statuses) are terminal. Container count
    aggregates project + space + group rows.
  - `objects.listRecent({ limit })` returns the N most-recently-updated
    rows, descending by updated_at. Excludes archived and excludes
    `workspace`/`group` from the activity feed (containers clutter
    "what did I just touch" recency).
  Both go through workspaceProcedure, so the workspace_id filter
  comes from the middleware-resolved ctx.workspace.id rather than
  any user input.

* app/(app)/[workspaceSlug]/page.tsx: rewrite to consume the new
  procedures via @trpc/react-query. Adds:
  - Skeleton loading state (no flash of zeros).
  - Empty state with a "New task" CTA on workspaces with no objects.
  - Real "X ago" labels on the recent feed.
  - Click-through links from recent rows to /{slug}/{id}.
  - A locally-mounted CreateObjectDialog instance independent of the
    global one in AppShell so the empty-state CTA can pre-seed
    defaultType="task" without coordinating shared state.

* components/ui/skeleton.tsx: new (standard shadcn pulse skeleton).
  Used by the dashboard but reusable across the app.

The scaffolded "Due this week" stat is dropped: `objects` has no
due_at column and the task explicitly preferred dropping a card to
schema-creep.

`pnpm lint && pnpm type-check` clean. Closes
plans/Plan-daily-driver-finish/Epic-shipping-the-shell/
Task-wire-workspace-home-dashboard.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-06-02 00:17:29 -05:00
parent 1c9deea3fd
commit f64d307f72
4 changed files with 329 additions and 69 deletions

View file

@ -1,37 +1,100 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import {
ArrowRight,
CheckCircle2,
CircleDashed,
ClipboardList,
FileText,
FolderKanban,
LayoutDashboard,
LayoutGrid,
Plus,
Presentation,
Sparkles,
type LucideIcon,
} from "lucide-react";
import { CreateObjectDialog } from "@/components/objects";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/lib/trpc";
import { usePanelStore } from "@/lib/stores/panel-store";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
const stats = [
{ label: "Open tasks", value: "24", delta: "+3 this week" },
{ label: "Due this week", value: "8", delta: "2 overdue" },
{ label: "Lists", value: "12", delta: "Across teams" },
];
/**
* Minimal "X ago" formatter. We avoid pulling in date-fns or a Yjs-aware
* relative-time helper for a single use site; the home page just needs
* coarse-grained recency labels.
*/
function relativeTime(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
const ms = Date.now() - date.getTime();
if (Number.isNaN(ms)) return String(value);
const secs = Math.max(1, Math.round(ms / 1000));
if (secs < 60) return `${secs}s ago`;
const mins = Math.round(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString();
}
const recent = [
{ title: "Sprint planning", meta: "List · Updated 2h ago", status: "done" as const },
{ title: "Design review — navigation", meta: "Task · Updated yesterday", status: "progress" as const },
{ title: "Q1 roadmap doc", meta: "Doc · Edited 3d ago", status: "progress" as const },
];
const TYPE_LABELS: Record<string, string> = {
task: "Task",
document: "Document",
whiteboard: "Whiteboard",
space: "Space",
project: "Project",
group: "Group",
form: "Form",
};
const TYPE_ICONS: Record<string, LucideIcon> = {
task: ClipboardList,
document: FileText,
whiteboard: Presentation,
space: LayoutGrid,
project: FolderKanban,
group: FolderKanban,
form: ClipboardList,
};
function isDoneStatus(status: string | null): boolean {
return status === "done" || status === "closed";
}
export default function WorkspaceHomePage() {
const params = useParams();
const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const openPanel = usePanelStore((s) => s.open);
const [createOpen, setCreateOpen] = useState(false);
const [createType, setCreateType] = useState<string | undefined>(undefined);
const statsQuery = api.objects.stats.useQuery(
{ workspace: workspaceSlug! },
{ enabled: Boolean(workspaceSlug) },
);
const recentQuery = api.objects.listRecent.useQuery(
{ workspace: workspaceSlug!, limit: 5 },
{ enabled: Boolean(workspaceSlug) },
);
const name = workspace?.name ?? "Workspace";
const recent = recentQuery.data?.objects ?? [];
return (
<div className="flex min-h-full flex-col bg-background">
@ -74,10 +137,13 @@ export default function WorkspaceHomePage() {
size="sm"
variant="secondary"
className="gap-2 bg-primary-foreground/15 text-primary-foreground hover:bg-primary-foreground/25"
onClick={() => openPanel("object-detail", "demo-object")}
onClick={() => {
setCreateType("task");
setCreateOpen(true);
}}
>
Sample side panel
<ArrowRight className="size-4" />
<Plus className="size-4" />
New task
</Button>
</div>
</div>
@ -87,23 +153,21 @@ export default function WorkspaceHomePage() {
<section>
<h2 className="text-sm font-semibold text-foreground">Quick stats</h2>
<p className="mt-1 text-sm text-muted-foreground">
Placeholder metrics until your data layer is connected.
A snapshot of what&apos;s live in this workspace right now.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
{stats.map((s) => (
<div
key={s.label}
className="rounded-lg border border-border bg-card p-4 shadow-sm"
>
<p className="text-xs font-medium text-muted-foreground">
{s.label}
</p>
<p className="mt-2 text-2xl font-semibold tabular-nums text-foreground">
{s.value}
</p>
<p className="mt-1 text-xs text-muted-foreground">{s.delta}</p>
</div>
))}
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<StatCard
label="Open tasks"
value={statsQuery.data?.openTasks}
hint="Excludes done and closed"
isLoading={statsQuery.isLoading}
/>
<StatCard
label="Projects, spaces & groups"
value={statsQuery.data?.containers}
hint="All container objects"
isLoading={statsQuery.isLoading}
/>
</div>
</section>
@ -116,41 +180,134 @@ export default function WorkspaceHomePage() {
Recent activity
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Latest updates across this workspace (sample rows).
Latest updates across this workspace.
</p>
</div>
<Badge variant="secondary" className="shrink-0">
Beta
Live
</Badge>
</div>
{recentQuery.isLoading ? (
<ul className="mt-4 divide-y divide-border rounded-lg border border-border bg-card">
{recent.map((item) => (
<li
key={item.title}
className="flex items-start gap-3 px-4 py-3 first:rounded-t-lg last:rounded-b-lg"
>
<span className="mt-0.5 text-muted-foreground">
{item.status === "done" ? (
<CheckCircle2 className="size-4 text-teal" />
) : (
<CircleDashed className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.meta}</p>
{[0, 1, 2].map((i) => (
<li key={i} className="flex items-start gap-3 px-4 py-3">
<Skeleton className="mt-0.5 size-4 rounded-full" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-1/3" />
</div>
<Badge
variant={item.status === "done" ? "secondary" : "outline"}
className="shrink-0 capitalize"
>
{item.status === "done" ? "Done" : "In progress"}
</Badge>
<Skeleton className="h-5 w-16" />
</li>
))}
</ul>
) : recent.length === 0 ? (
<EmptyRecent
onCreate={() => {
setCreateType("task");
setCreateOpen(true);
}}
/>
) : (
<ul className="mt-4 divide-y divide-border rounded-lg border border-border bg-card">
{recent.map((item) => {
if (!workspaceSlug) return null;
const Icon = TYPE_ICONS[item.type] ?? ClipboardList;
const typeLabel = TYPE_LABELS[item.type] ?? item.type;
const done = isDoneStatus(item.status);
return (
<li
key={item.id}
className="first:rounded-t-lg last:rounded-b-lg"
>
<Link
href={`/${workspaceSlug}/${item.id}`}
className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/40"
>
<span className="mt-0.5 text-muted-foreground">
{done ? (
<CheckCircle2 className="size-4 text-teal" />
) : (
<Icon className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
{item.title || "Untitled"}
</p>
<p className="text-xs text-muted-foreground">
{typeLabel} · Updated {relativeTime(item.updatedAt)}
</p>
</div>
<Badge
variant={done ? "secondary" : "outline"}
className="shrink-0 capitalize"
>
{done
? "Done"
: item.status?.replace("_", " ") ?? "Active"}
</Badge>
<ArrowRight className="mt-0.5 size-4 shrink-0 text-muted-foreground/60" />
</Link>
</li>
);
})}
</ul>
)}
</section>
</div>
<CreateObjectDialog
open={createOpen}
onOpenChange={setCreateOpen}
defaultType={createType}
/>
</div>
);
}
function StatCard({
label,
value,
hint,
isLoading,
}: {
label: string;
value: number | undefined;
hint: string;
isLoading: boolean;
}) {
return (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
{isLoading ? (
<Skeleton className="mt-2 h-8 w-16" />
) : (
<p className="mt-2 text-2xl font-semibold tabular-nums text-foreground">
{value ?? 0}
</p>
)}
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
</div>
);
}
function EmptyRecent({ onCreate }: { onCreate: () => void }) {
return (
<div className="mt-4 flex flex-col items-center gap-3 rounded-lg border border-dashed border-border bg-card px-6 py-10 text-center">
<CircleDashed className="size-6 text-muted-foreground" />
<div>
<p className="text-sm font-medium text-foreground">
Nothing here yet
</p>
<p className="mt-1 text-xs text-muted-foreground">
Create your first task to see recent activity here.
</p>
</div>
<Button size="sm" onClick={onCreate} className="gap-1">
<Plus className="size-4" />
New task
</Button>
</div>
);
}

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
export function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}

View file

@ -3,10 +3,13 @@ import { z } from "zod";
import {
and,
asc,
desc,
eq,
getTableColumns,
inArray,
isNull,
notInArray,
or,
sql,
} from "drizzle-orm";
import { objectTypes } from "@tasks/shared";
@ -398,4 +401,84 @@ export const objectsRouter = router({
return { ok: true as const, action: "add" as const };
}),
/**
* Workspace-home dashboard summary: counts that don't require expensive joins.
* "Open tasks" treats null status as open (a task with no explicit status
* isn't done). Terminal statuses are `done` and `closed` per
* `packages/shared/src/types/objects.ts`.
*/
stats: workspaceProcedure.query(async ({ ctx }) => {
const TERMINAL_STATUSES = ["done", "closed"] as const;
const CONTAINER_TYPES = ["project", "space", "group"] as const;
const [openTasksRow] = await ctx.db
.select({ count: sql<number>`count(*)::int`.mapWith(Number) })
.from(objects)
.where(
and(
eq(objects.workspaceId, ctx.workspace.id),
isNull(objects.archivedAt),
eq(objects.type, "task"),
or(
isNull(objects.status),
notInArray(objects.status, [...TERMINAL_STATUSES]),
),
),
);
const [containersRow] = await ctx.db
.select({ count: sql<number>`count(*)::int`.mapWith(Number) })
.from(objects)
.where(
and(
eq(objects.workspaceId, ctx.workspace.id),
isNull(objects.archivedAt),
inArray(objects.type, [...CONTAINER_TYPES]),
),
);
return {
openTasks: openTasksRow?.count ?? 0,
containers: containersRow?.count ?? 0,
};
}),
/**
* Recently-updated objects for the workspace-home "Recent activity" panel.
* Excludes archived. Excludes group/space rows from the feed (they show up
* elsewhere and clutter the recency view).
*/
listRecent: workspaceProcedure
.input(
z.object({
limit: z.number().int().positive().max(50).optional(),
}),
)
.query(async ({ ctx, input }) => {
const limit = input.limit ?? 5;
const FEED_EXCLUDED_TYPES = ["workspace", "group"] as const;
const rows = await ctx.db
.select({
id: objects.id,
title: objects.title,
type: objects.type,
status: objects.status,
icon: objects.icon,
updatedAt: objects.updatedAt,
})
.from(objects)
.where(
and(
eq(objects.workspaceId, ctx.workspace.id),
isNull(objects.archivedAt),
notInArray(objects.type, [...FEED_EXCLUDED_TYPES]),
),
)
.orderBy(desc(objects.updatedAt))
.limit(limit);
return { objects: rows };
}),
});

View file

@ -4,12 +4,12 @@ slug: wire-workspace-home-dashboard
title: Replace hardcoded dashboard mocks with real tRPC queries
plan_slug: daily-driver-finish
epic_slug: shipping-the-shell
status: ready
status: done
priority: P0
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-06-01"
updated_at: "2026-06-02"
---
# Task summary
@ -37,11 +37,18 @@ The page is a Client Component (`"use client"`). It already pulls `currentWorksp
## Subtasks
- [ ] Audit `apps/web/server/routers/objects.ts` for existing `count` / `listRecent` procedures.
- [ ] Add the missing procedure(s) if needed, with zod inputs and workspace scoping.
- [ ] Replace `stats` and `recent` arrays in `[workspaceSlug]/page.tsx` with `api.objects.*.useQuery()` calls.
- [ ] Add a skeleton state and an empty state.
- [ ] Wire the empty-state CTA to `CreateObjectDialog`.
- [x] Audit `apps/web/server/routers/objects.ts` — no `stats` or `listRecent` existed.
- [x] Added `objects.stats` (open-tasks count + container-types count) and `objects.listRecent({ limit })`. Both go through `workspaceProcedure`, so the `workspace_id` filter is enforced by the middleware, not just by the query body.
- [x] Replaced `stats` and `recent` arrays in `[workspaceSlug]/page.tsx` with real queries.
- [x] Added skeleton states (`apps/web/components/ui/skeleton.tsx`, new) and an empty state with a CTA.
- [x] Wired the empty-state CTA to a locally-mounted `CreateObjectDialog` instance with `defaultType: "task"`. The page-level dialog is independent of the global `AppShell` create dialog so it can pre-seed its `defaultType` without coordinating shared state.
### Decisions made vs. the scaffold
- **"Due this week" card dropped.** `objects` has no `due_at` column. Per the task's own constraint ("drop a card rather than schema-creep this task") it's gone, leaving a 2-up grid: open tasks + projects/spaces/groups.
- **Recent feed excludes `workspace` and `group` rows.** Container objects clutter a "what did I touch lately" view; the user wants to see the tasks/docs/whiteboards they actually edited.
- **Status "done" semantics** match `packages/shared/src/types/objects.ts`: `done` and `closed` are terminal. Null status is treated as open (a fresh task with no explicit status isn't done).
- **Time-ago helper inlined.** Single use site; not worth pulling in `date-fns` or building a shared hook.
## Owner or assignee
@ -49,7 +56,7 @@ Unassigned
## Status
ready
done
## Estimation
@ -57,10 +64,10 @@ M
## Acceptance criteria
- [ ] No hardcoded numbers or hardcoded titles remain in `apps/web/app/(app)/[workspaceSlug]/page.tsx`.
- [ ] Loading state renders without a flash of zeros.
- [ ] Empty state for a brand-new workspace renders a CTA.
- [ ] All queries filter by `workspace_id`.
- [x] No hardcoded numbers or hardcoded titles remain in `apps/web/app/(app)/[workspaceSlug]/page.tsx`.
- [x] Loading state renders skeletons rather than a flash of zeros.
- [x] Empty state for a brand-new workspace renders a "New task" CTA wired to `CreateObjectDialog`.
- [x] All queries filter by `workspace_id` (enforced through `workspaceProcedure`).
## Links to related Epic / Plan