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:
parent
1c9deea3fd
commit
f64d307f72
4 changed files with 329 additions and 69 deletions
|
|
@ -1,37 +1,100 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CircleDashed,
|
CircleDashed,
|
||||||
|
ClipboardList,
|
||||||
|
FileText,
|
||||||
|
FolderKanban,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
LayoutGrid,
|
||||||
|
Plus,
|
||||||
|
Presentation,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { CreateObjectDialog } from "@/components/objects";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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 { usePanelStore } from "@/lib/stores/panel-store";
|
||||||
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const stats = [
|
/**
|
||||||
{ label: "Open tasks", value: "24", delta: "+3 this week" },
|
* Minimal "X ago" formatter. We avoid pulling in date-fns or a Yjs-aware
|
||||||
{ label: "Due this week", value: "8", delta: "2 overdue" },
|
* relative-time helper for a single use site; the home page just needs
|
||||||
{ label: "Lists", value: "12", delta: "Across teams" },
|
* 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 = [
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
{ title: "Sprint planning", meta: "List · Updated 2h ago", status: "done" as const },
|
task: "Task",
|
||||||
{ title: "Design review — navigation", meta: "Task · Updated yesterday", status: "progress" as const },
|
document: "Document",
|
||||||
{ title: "Q1 roadmap doc", meta: "Doc · Edited 3d ago", status: "progress" as const },
|
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() {
|
export default function WorkspaceHomePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspaceSlug =
|
||||||
|
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
|
||||||
|
|
||||||
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
|
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
|
||||||
const openPanel = usePanelStore((s) => s.open);
|
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 name = workspace?.name ?? "Workspace";
|
||||||
|
const recent = recentQuery.data?.objects ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-col bg-background">
|
<div className="flex min-h-full flex-col bg-background">
|
||||||
|
|
@ -74,10 +137,13 @@ export default function WorkspaceHomePage() {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="gap-2 bg-primary-foreground/15 text-primary-foreground hover:bg-primary-foreground/25"
|
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
|
<Plus className="size-4" />
|
||||||
<ArrowRight className="size-4" />
|
New task
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -87,23 +153,21 @@ export default function WorkspaceHomePage() {
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-foreground">Quick stats</h2>
|
<h2 className="text-sm font-semibold text-foreground">Quick stats</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Placeholder metrics until your data layer is connected.
|
A snapshot of what's live in this workspace right now.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
{stats.map((s) => (
|
<StatCard
|
||||||
<div
|
label="Open tasks"
|
||||||
key={s.label}
|
value={statsQuery.data?.openTasks}
|
||||||
className="rounded-lg border border-border bg-card p-4 shadow-sm"
|
hint="Excludes done and closed"
|
||||||
>
|
isLoading={statsQuery.isLoading}
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
/>
|
||||||
{s.label}
|
<StatCard
|
||||||
</p>
|
label="Projects, spaces & groups"
|
||||||
<p className="mt-2 text-2xl font-semibold tabular-nums text-foreground">
|
value={statsQuery.data?.containers}
|
||||||
{s.value}
|
hint="All container objects"
|
||||||
</p>
|
isLoading={statsQuery.isLoading}
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{s.delta}</p>
|
/>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -116,41 +180,134 @@ export default function WorkspaceHomePage() {
|
||||||
Recent activity
|
Recent activity
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Latest updates across this workspace (sample rows).
|
Latest updates across this workspace.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="shrink-0">
|
<Badge variant="secondary" className="shrink-0">
|
||||||
Beta
|
Live
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{recentQuery.isLoading ? (
|
||||||
<ul className="mt-4 divide-y divide-border rounded-lg border border-border bg-card">
|
<ul className="mt-4 divide-y divide-border rounded-lg border border-border bg-card">
|
||||||
{recent.map((item) => (
|
{[0, 1, 2].map((i) => (
|
||||||
<li
|
<li key={i} className="flex items-start gap-3 px-4 py-3">
|
||||||
key={item.title}
|
<Skeleton className="mt-0.5 size-4 rounded-full" />
|
||||||
className="flex items-start gap-3 px-4 py-3 first:rounded-t-lg last:rounded-b-lg"
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
>
|
<Skeleton className="h-4 w-2/3" />
|
||||||
<span className="mt-0.5 text-muted-foreground">
|
<Skeleton className="h-3 w-1/3" />
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Skeleton className="h-5 w-16" />
|
||||||
variant={item.status === "done" ? "secondary" : "outline"}
|
|
||||||
className="shrink-0 capitalize"
|
|
||||||
>
|
|
||||||
{item.status === "done" ? "Done" : "In progress"}
|
|
||||||
</Badge>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</section>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
apps/web/components/ui/skeleton.tsx
Normal file
13
apps/web/components/ui/skeleton.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,13 @@ import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
asc,
|
asc,
|
||||||
|
desc,
|
||||||
eq,
|
eq,
|
||||||
getTableColumns,
|
getTableColumns,
|
||||||
inArray,
|
inArray,
|
||||||
isNull,
|
isNull,
|
||||||
|
notInArray,
|
||||||
|
or,
|
||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { objectTypes } from "@tasks/shared";
|
import { objectTypes } from "@tasks/shared";
|
||||||
|
|
@ -398,4 +401,84 @@ export const objectsRouter = router({
|
||||||
|
|
||||||
return { ok: true as const, action: "add" as const };
|
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 };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ slug: wire-workspace-home-dashboard
|
||||||
title: Replace hardcoded dashboard mocks with real tRPC queries
|
title: Replace hardcoded dashboard mocks with real tRPC queries
|
||||||
plan_slug: daily-driver-finish
|
plan_slug: daily-driver-finish
|
||||||
epic_slug: shipping-the-shell
|
epic_slug: shipping-the-shell
|
||||||
status: ready
|
status: done
|
||||||
priority: P0
|
priority: P0
|
||||||
tenant_id: global
|
tenant_id: global
|
||||||
owner: unassigned
|
owner: unassigned
|
||||||
cursor_todo_id: null
|
cursor_todo_id: null
|
||||||
updated_at: "2026-06-01"
|
updated_at: "2026-06-02"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Task summary
|
# Task summary
|
||||||
|
|
@ -37,11 +37,18 @@ The page is a Client Component (`"use client"`). It already pulls `currentWorksp
|
||||||
|
|
||||||
## Subtasks
|
## Subtasks
|
||||||
|
|
||||||
- [ ] Audit `apps/web/server/routers/objects.ts` for existing `count` / `listRecent` procedures.
|
- [x] Audit `apps/web/server/routers/objects.ts` — no `stats` or `listRecent` existed.
|
||||||
- [ ] Add the missing procedure(s) if needed, with zod inputs and workspace scoping.
|
- [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.
|
||||||
- [ ] Replace `stats` and `recent` arrays in `[workspaceSlug]/page.tsx` with `api.objects.*.useQuery()` calls.
|
- [x] Replaced `stats` and `recent` arrays in `[workspaceSlug]/page.tsx` with real queries.
|
||||||
- [ ] Add a skeleton state and an empty state.
|
- [x] Added skeleton states (`apps/web/components/ui/skeleton.tsx`, new) and an empty state with a CTA.
|
||||||
- [ ] Wire the empty-state CTA to `CreateObjectDialog`.
|
- [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
|
## Owner or assignee
|
||||||
|
|
||||||
|
|
@ -49,7 +56,7 @@ Unassigned
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
ready
|
done
|
||||||
|
|
||||||
## Estimation
|
## Estimation
|
||||||
|
|
||||||
|
|
@ -57,10 +64,10 @@ M
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] No hardcoded numbers or hardcoded titles remain in `apps/web/app/(app)/[workspaceSlug]/page.tsx`.
|
- [x] No hardcoded numbers or hardcoded titles remain in `apps/web/app/(app)/[workspaceSlug]/page.tsx`.
|
||||||
- [ ] Loading state renders without a flash of zeros.
|
- [x] Loading state renders skeletons rather than a flash of zeros.
|
||||||
- [ ] Empty state for a brand-new workspace renders a CTA.
|
- [x] Empty state for a brand-new workspace renders a "New task" CTA wired to `CreateObjectDialog`.
|
||||||
- [ ] All queries filter by `workspace_id`.
|
- [x] All queries filter by `workspace_id` (enforced through `workspaceProcedure`).
|
||||||
|
|
||||||
## Links to related Epic / Plan
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue