# Authoring a Custom View

> Walkthrough from struere add view to a live dashboard

This walkthrough takes you from an empty project to a rendered dashboard. The example builds an operations dashboard with KPIs, an upcoming-sessions table, and a teacher-load list — using only components from `"struere/view"`.

## 1. Scaffold

```bash
bunx struere add view operations-dashboard
```

This creates `views/operations-dashboard.tsx` with a working starter:

```tsx
import { defineView, Card, DataTable, Stack, EmptyState, useViewQuery } from "struere/view"

export default defineView({
  name: "Operations Dashboard",
  slug: "operations-dashboard",
  description: "Operations Dashboard dashboard",
  queries: {
    rows: { type: "todo", limit: 100 },
  },
  render: () => {
    const rows = useViewQuery<{ id: string; title: string }>("rows")
    if (!rows.data) return null
    if (rows.data.length === 0) {
      return <EmptyState title="Nothing here yet" description="Define a 'todo' entity type or change the query." />
    }
    return (
      <Stack gap="md">
        <Card title="Operations Dashboard">
          <DataTable
            rows={rows.data}
            rowKey={(r) => r.id}
            columns={[{ key: "title", header: "Title" }]}
          />
        </Card>
      </Stack>
    )
  },
})
```

Replace `"todo"` with an entity type slug that exists in your project.

## 2. Develop

Run `struere dev` in another terminal. The watcher picks up `views/` alongside `agents/`, `entity-types/`, and the rest. Every save triggers a recompile and resync:

```
~  Changed views/operations-dashboard.tsx
✓ Synced 1 view to development
```

If the compile fails, the CLI prints the file, line, column, and a fix hint. See [Custom Views Compile Errors](../reference/view-compile-errors) for the catalogue.

## 3. Full Example

Replace the starter with a real dashboard. This is the canonical example — KPIs across the top, an upcoming-sessions table, and a teacher-load list:

```tsx
import {
  defineView,
  Card,
  DataTable,
  DateTime,
  EmptyState,
  Grid,
  KPI,
  ProgressList,
  Stack,
  StatusBadge,
  useIndex,
  useViewQuery,
} from "struere/view"

interface EntityRow {
  _id: string
  data: Record<string, unknown>
}

interface SessionRow {
  id: string
  status: string
  scheduledAt: string
  teacherId: string
  studentId: string
  subject: string
  duration?: number
}

interface PersonRow {
  id: string
  name: string
  grade?: string
}

function flatten<T>(rows: EntityRow[] | undefined): T[] {
  if (!rows) return []
  return rows.map((row) => ({ id: row._id, ...(row.data ?? {}) })) as T[]
}

export default defineView({
  name: "Operations Dashboard",
  slug: "operations-dashboard",
  description: "Live overview of sessions, students, and teachers.",
  queries: {
    sessions: { type: "session", limit: 100 },
    students: { type: "student", limit: 100 },
    teachers: { type: "teacher", limit: 100 },
  },
  render: () => {
    const sessionsQ = useViewQuery<EntityRow>("sessions")
    const studentsQ = useViewQuery<EntityRow>("students")
    const teachersQ = useViewQuery<EntityRow>("teachers")

    if (sessionsQ.loading || studentsQ.loading || teachersQ.loading) {
      return <EmptyState title="Loading dashboard..." />
    }

    const sessions = flatten<SessionRow>(sessionsQ.data)
    const students = flatten<PersonRow>(studentsQ.data)
    const teachers = flatten<PersonRow>(teachersQ.data)

    const studentById = useIndex(students, "id")
    const teacherById = useIndex(teachers, "id")

    const now = Date.now()
    const upcoming = sessions
      .filter((s) => s.status === "scheduled" && new Date(s.scheduledAt).getTime() >= now)
      .sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime())
      .slice(0, 10)

    const teacherLoad = teachers
      .map((teacher) => ({
        teacher,
        scheduled: sessions.filter((s) => s.teacherId === teacher.id && s.status === "scheduled").length,
        completed: sessions.filter((s) => s.teacherId === teacher.id && s.status === "completed").length,
      }))
      .sort((a, b) => b.scheduled - a.scheduled)
      .slice(0, 8)

    const maxLoad = Math.max(1, teacherLoad[0]?.scheduled ?? 1)
    const scheduledCount = sessions.filter((s) => s.status === "scheduled").length
    const completedCount = sessions.filter((s) => s.status === "completed").length

    return (
      <Stack direction="column" gap="md">
        <Grid columns={{ sm: 1, md: 2, lg: 4 }} gap="md">
          <KPI label="Scheduled" value={scheduledCount} hint="Future and pending classes" tone="info" />
          <KPI label="Completed" value={completedCount} hint="Historical classes" tone="success" />
          <KPI label="Students" value={students.length} hint="Active student records" />
          <KPI label="Teachers" value={teachers.length} hint="Available tutors" />
        </Grid>

        <Grid columns={{ sm: 1, lg: 2 }} gap="md">
          <Card title="Upcoming sessions">
            <DataTable<SessionRow>
              rows={upcoming}
              rowKey={(row) => row.id}
              emptyState="No upcoming sessions."
              columns={[
                {
                  key: "time",
                  header: "Time",
                  cell: (row) => <DateTime value={row.scheduledAt} format="short" />,
                },
                {
                  key: "student",
                  header: "Student",
                  cell: (row) => studentById.get(row.studentId)?.name ?? row.studentId,
                },
                {
                  key: "teacher",
                  header: "Teacher",
                  cell: (row) => teacherById.get(row.teacherId)?.name ?? row.teacherId,
                },
                {
                  key: "subject",
                  header: "Subject",
                  cell: (row) => row.subject ?? "-",
                },
                {
                  key: "status",
                  header: "Status",
                  cell: (row) => <StatusBadge status={row.status} />,
                },
              ]}
            />
          </Card>

          <Card title="Teacher load">
            <ProgressList
              items={teacherLoad.map((row) => ({
                id: row.teacher.id,
                label: row.teacher.name,
                value: row.scheduled,
                max: maxLoad,
                hint: `${row.completed} completed`,
                tone: "info",
              }))}
            />
          </Card>
        </Grid>
      </Stack>
    )
  },
})
```

Save the file. The dashboard updates within seconds — no refresh needed. Any change to a session, student, or teacher entity will flow through the live Convex subscription and the view re-renders.

## 4. Promote

When the view is ready for production, push it with the rest of your project:

```bash
bunx struere deploy
```

`deploy` compiles every view, validates against the same AST rules as `dev`, and uploads the bundle to the `production` environment.

## CLI Commands for Views

| Command | What it does for views |
|---------|------------------------|
| `struere add view <name>` | Scaffolds `views/<slug>.tsx` with a working starter |
| `struere dev` | Watches `views/`, recompiles on save, syncs to `development` and `eval` |
| `struere sync` | One-shot compile and upload to `development` and `eval` |
| `struere deploy` | Compiles and uploads to `production` |
| `struere pull` | Writes `views/<slug>.tsx` from the stored source for each remote view |
| `struere status` | Compares the local compiled hash against the remote hash and marks views as new, modified, or in sync |

## Where the View Renders

In the dashboard, navigate to **Views** in the sidebar. The slug becomes the URL. Selecting a view mounts the compiled bundle inside a sandbox boundary; every `useViewQuery` opens a live Convex subscription, scoped to the viewer's roles and the current environment.
