diff --git a/apps/web/app/(app)/[workspaceSlug]/page.tsx b/apps/web/app/(app)/[workspaceSlug]/page.tsx index 74e2839..b0918c3 100644 --- a/apps/web/app/(app)/[workspaceSlug]/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/page.tsx @@ -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 = { + task: "Task", + document: "Document", + whiteboard: "Whiteboard", + space: "Space", + project: "Project", + group: "Group", + form: "Form", +}; + +const TYPE_ICONS: Record = { + 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(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 (
@@ -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 - + + New task
@@ -87,23 +153,21 @@ export default function WorkspaceHomePage() {

Quick stats

- Placeholder metrics until your data layer is connected. + A snapshot of what's live in this workspace right now.

-
- {stats.map((s) => ( -
-

- {s.label} -

-

- {s.value} -

-

{s.delta}

-
- ))} +
+ +
@@ -116,41 +180,134 @@ export default function WorkspaceHomePage() { Recent activity

- Latest updates across this workspace (sample rows). + Latest updates across this workspace.

- Beta + Live -
    - {recent.map((item) => ( -
  • - - {item.status === "done" ? ( - - ) : ( - - )} - -
    -

    {item.title}

    -

    {item.meta}

    -
    - - {item.status === "done" ? "Done" : "In progress"} - -
  • - ))} -
+ + {recentQuery.isLoading ? ( +
    + {[0, 1, 2].map((i) => ( +
  • + +
    + + +
    + +
  • + ))} +
+ ) : recent.length === 0 ? ( + { + setCreateType("task"); + setCreateOpen(true); + }} + /> + ) : ( +
    + {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 ( +
  • + + + {done ? ( + + ) : ( + + )} + +
    +

    + {item.title || "Untitled"} +

    +

    + {typeLabel} · Updated {relativeTime(item.updatedAt)} +

    +
    + + {done + ? "Done" + : item.status?.replace("_", " ") ?? "Active"} + + + +
  • + ); + })} +
+ )} + + + + ); +} + +function StatCard({ + label, + value, + hint, + isLoading, +}: { + label: string; + value: number | undefined; + hint: string; + isLoading: boolean; +}) { + return ( +
+

{label}

+ {isLoading ? ( + + ) : ( +

+ {value ?? 0} +

+ )} +

{hint}

+
+ ); +} + +function EmptyRecent({ onCreate }: { onCreate: () => void }) { + return ( +
+ +
+

+ Nothing here yet +

+

+ Create your first task to see recent activity here. +

+
+
); } diff --git a/apps/web/components/ui/skeleton.tsx b/apps/web/components/ui/skeleton.tsx new file mode 100644 index 0000000..09a0298 --- /dev/null +++ b/apps/web/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils"; + +export function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} diff --git a/apps/web/server/routers/objects.ts b/apps/web/server/routers/objects.ts index 0ae4fa1..c302560 100644 --- a/apps/web/server/routers/objects.ts +++ b/apps/web/server/routers/objects.ts @@ -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`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`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 }; + }), }); diff --git a/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-wire-workspace-home-dashboard.md b/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-wire-workspace-home-dashboard.md index b1b233c..3e54049 100644 --- a/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-wire-workspace-home-dashboard.md +++ b/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-wire-workspace-home-dashboard.md @@ -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