# Interaction views

> Unified view and task-list model for sprint, backlog, and timeline contexts; query parameters, manual ordering, and shared REST paths.

- Repository: Paca-AI/paca
- GitHub: https://github.com/Paca-AI/paca
- Human docs: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25
- Complete Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/llms-full.txt

## Source Files

- `docs/architecture/interaction-views.md`
- `docs/architecture/manual-sort-algorithm.md`
- `services/api/internal/transport/http/handler/view_handler.go`
- `services/api/internal/transport/http/handler/task_handler.go`
- `apps/web/src/lib/interaction-api.ts`

---

---
title: "Interaction views"
description: "Unified view and task-list model for sprint, backlog, and timeline contexts; query parameters, manual ordering, and shared REST paths."
---

Paca models sprint, product-backlog, and timeline interactions through one saved-view resource (`/api/v1/projects/:projectId/views`) and one task-list endpoint (`/api/v1/projects/:projectId/tasks`). Each view stores presentation settings and reusable filters in a JSON `config`; manual task order is persisted separately in `view_task_positions` and read or written through `/views/:viewId/task-positions`.

## Core model

A saved view belongs to exactly one project and one interaction context:

| Context | Query param | Scope | `sprint_id` on view |
|---|---|---|---|
| Sprint | `context=sprint` | Per-sprint tabs | Set (required in query) |
| Product backlog | `context=backlog` | Project-level | `null` |
| Timeline | `context=timeline` | Project-level | `null` |

Views are stored in `sprint_views` with `view_context` set to `sprint`, `backlog`, or `timeline`. Sprint-scoped rows also carry a `sprint_id`; backlog and timeline rows have `sprint_id = null` but always include `project_id`.

Supported layout types (`view_type`):

| `view_type` | UI layout | Typical use |
|---|---|---|
| `table` | Table | Backlog grouped by sprint; sprint task lists |
| `board` | Board (kanban) | Sprint status columns |
| `roadmap` | Roadmap | Timeline Epic bars |
| `plugin` | Plugin | WASM frontend extension |

```mermaid
flowchart LR
  subgraph UI["Web app"]
    IL["interaction-layout.tsx"]
    API["interaction-api.ts"]
  end
  subgraph REST["API /api/v1"]
    V["/projects/:id/views"]
    T["/projects/:id/tasks"]
    P["/views/:viewId/task-positions"]
  end
  subgraph DB["Postgres"]
    SV["sprint_views"]
    VTP["view_task_positions"]
    TK["tasks"]
  end
  IL --> API
  API --> V
  API --> T
  API --> P
  V --> SV
  P --> VTP
  T --> TK
```

<Info>
The UI keeps sprint, backlog, and timeline as distinct pages, but all three share the same REST paths. Context is selected with the `?context=` query parameter rather than separate route families.
</Info>

## View REST API

All view endpoints live under `/api/v1/projects/:projectId/views`. Sprint context additionally requires `?sprint_id=<uuid>`. When `context` is omitted, the handler defaults to `sprint`.

<ParamField body="context" type="string">
Interaction context. One of `sprint`, `backlog`, or `timeline`. Defaults to `sprint` when omitted.
</ParamField>

<ParamField body="sprint_id" type="uuid" required>
Required when `context=sprint`. Ignored for backlog and timeline.
</ParamField>

### Endpoint inventory

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/views?context=…` | List views for the context |
| `POST` | `/views?context=…` | Create a view |
| `GET` | `/views/:viewId` | Get one view |
| `PATCH` | `/views/:viewId` | Update name, type, or config |
| `DELETE` | `/views/:viewId` | Delete a view |
| `PUT` | `/views/positions?context=…` | Reorder view tabs |
| `GET` | `/views/:viewId/task-positions` | List manual positions |
| `PUT` | `/views/:viewId/task-positions` | Bulk upsert positions |
| `PUT` | `/views/:viewId/task-positions/:taskId` | Upsert one position |

:::endpoint GET /api/v1/projects/:projectId/views
List saved views for the given interaction context. Sprint context scopes to one sprint via `sprint_id`.
:::

<RequestExample>

```bash
# Sprint views
curl -b cookies.txt \
  "/api/v1/projects/{projectId}/views?context=sprint&sprint_id={sprintId}"

# Backlog views
curl -b cookies.txt \
  "/api/v1/projects/{projectId}/views?context=backlog"

# Timeline views
curl -b cookies.txt \
  "/api/v1/projects/{projectId}/views?context=timeline"
```

</RequestExample>

<ResponseExample>

```json
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "…",
        "sprint_id": "…",
        "project_id": "…",
        "name": "Board",
        "view_type": "board",
        "config": {
          "column_by": "status",
          "filters": {
            "sprints": { "all": false, "items": { "{sprintId}": true } },
            "task_types": { "all": false, "items": { "normal": { "all": true } } }
          }
        },
        "position": 0,
        "created_at": "…",
        "updated_at": "…"
      }
    ]
  }
}
```

</ResponseExample>

:::endpoint POST /api/v1/projects/:projectId/views
Create a saved view. Body requires `name`; `view_type` defaults to `table` when omitted.
:::

<ParamField body="name" type="string" required>
Display name for the view tab.
</ParamField>

<ParamField body="view_type" type="string">
One of `table`, `board`, `roadmap`, or `plugin`. Defaults to `table`.
</ParamField>

<ParamField body="config" type="object">
Presentation and filter settings. See [View config](#view-config).
</ParamField>

<ParamField body="position" type="number">
Tab order position. Seeded views use `0`, `1`, …
</ParamField>

:::endpoint PUT /api/v1/projects/:projectId/views/positions
Reorder all view tabs for a context. Body must list every view ID in the desired order.
:::

<RequestExample>

```json
{ "view_ids": ["uuid-a", "uuid-b", "uuid-c"] }
```

</RequestExample>

<Warning>
Returns `400 VIEW_REORDER_INVALID` when the ID list length or membership does not exactly match the views for that context.
</Warning>

:::endpoint DELETE /api/v1/projects/:projectId/views/:viewId
Delete a view. Returns `409 VIEW_IS_LAST_VIEW` when it is the only remaining view in that context.
:::

## Unified task list

All interaction pages fetch tasks through one endpoint:

:::endpoint GET /api/v1/projects/:projectId/tasks
Paginated task list with filter query parameters. Cursor-based pagination via `cursor` and `next_cursor`.
:::

<ParamField body="sprint_id" type="uuid | null">
Single sprint filter. Pass the literal `null` for unscheduled backlog items only (`sprint_id IS NULL`).
</ParamField>

<ParamField body="sprint_ids" type="string">
Comma-separated sprint UUIDs for multi-sprint saved views.
</ParamField>

<ParamField body="status_id" type="uuid">
Filter to one status.
</ParamField>

<ParamField body="status_ids" type="string">
Comma-separated status UUIDs.
</ParamField>

<ParamField body="assignee_id" type="uuid | null">
Single assignee. Pass `null` for unassigned tasks.
</ParamField>

<ParamField body="assignee_ids" type="string">
Comma-separated assignee member UUIDs.
</ParamField>

<ParamField body="task_type_ids" type="string">
Comma-separated task type UUIDs. Timeline pages filter to Epic types here.
</ParamField>

<ParamField body="task_type_id" type="uuid | null">
Single type filter. Pass `null` for tasks with no type.
</ParamField>

<ParamField body="parent_task_id" type="uuid">
Return child tasks of a parent (subtasks).
</ParamField>

<ParamField body="view_id" type="uuid">
Optional. When set, enriches each task with `view_position` and `view_group_key` from that view's manual positions.
</ParamField>

<ParamField body="page_size" type="integer">
Page size. Default `20`, max `200`.
</ParamField>

<ParamField body="cursor" type="string">
Opaque cursor from a previous response's `next_cursor`.
</ParamField>

<ResponseField name="items" type="Task[]">
Task objects. May include `view_position` and `view_group_key` when `view_id` is supplied.
</ResponseField>

<ResponseField name="page_size" type="integer">
Applied page size.
</ResponseField>

<ResponseField name="next_cursor" type="string | null">
Cursor for the next page, or `null` when no more results.
</ResponseField>

### Context conventions

| Page | Typical filter seed in `config.filters` | Task query |
|---|---|---|
| Sprint | Current sprint in `filters.sprints` | `sprint_ids` from resolved view filters |
| Backlog | All sprints; `column_by = sprint` | `sprint_ids` from resolved view filters |
| Timeline | Epic-only `task_types` | `task_type_ids` from resolved view filters |

<Note>
Presentation belongs in top-level config keys (`column_by`, `sort_by`, `slice_by`). Query constraints belong in `config.filters`. The web app reads the active view config, resolves filters client-side, and passes the resulting IDs to `GET /tasks`.
</Note>

## View config

Each view stores presentation settings and reusable filters in `config` (JSONB on `sprint_views`).

```json
{
  "fields": ["title", "status", "assignee"],
  "column_by": "status",
  "swimlanes": "assignee",
  "sort_by": "manual",
  "field_sum": "count",
  "slice_by": "none",
  "collapsed_columns": ["status-uuid"],
  "filters": {
    "sprints": { "all": false, "items": { "sprint-uuid": true } },
    "statuses": { "all": true, "items": { "done-uuid": false } },
    "assignees": { "all": false, "items": { "member-uuid": true, "__unassigned": true } },
    "task_types": { "all": false, "items": { "normal": { "all": true } } }
  }
}
```

### Filter selectors

Filters use a recursive `FilterConfig` per dimension (`task_types`, `statuses`, `assignees`, `sprints`):

| Field | Semantics |
|---|---|
| `all: true` | Include everything by default; `items` entries act as exclusions |
| `all: false` | Include nothing by default; `items` entries act as inclusions |
| Item keys | Entity UUIDs or virtual groups |

The virtual key `"normal"` in `task_types` expands client-side to all non-system type IDs. Newly created task types are included automatically without updating stored views.

The web client resolves filters in `interaction-api.ts` (`resolveFilterConfig`, `resolveTaskTypeFilter`) and maps them to task query parameters in `interaction-layout.tsx`.

### Plugin views

When `view_type = "plugin"`, config also carries:

<ParamField body="plugin_manifest_id" type="string">
Reverse-DNS plugin manifest identifier (for example `com.paca.checklist`). Stored as `plugin_id` in the domain layer; exposed as `plugin_manifest_id` in API responses.
</ParamField>

<ParamField body="plugin_component" type="string">
Registered frontend extension component name.
</ParamField>

## Default seeded views

The API seeds default views on project and sprint creation (`view_defaults.go`).

### Project creation

| Context | Name | `view_type` | Key config |
|---|---|---|---|
| Backlog | Table | `table` | `column_by: sprint`, `filters.task_types: normal` |
| Timeline | Roadmap | `roadmap` | `filters.task_types`: Epic system type only |

### Sprint creation

| Name | `view_type` | Position | Key config |
|---|---|---|---|
| Board | `board` | 0 | `column_by: status`, current sprint in `filters.sprints`, backlog-status columns collapsed |
| Table | `table` | 1 | `column_by: status`, current sprint in `filters.sprints` |

Both sprint defaults filter to non-system task types via the `"normal"` virtual group. Seeding is best-effort: project or sprint creation succeeds even if a view seed fails.

## Web app data flow

For every interaction page (`interaction-layout.tsx`):

<Steps>
<Step title="Load views">
Fetch views with `listViewsByContext(projectId, context, sprintId?)` using the context query string.
</Step>
<Step title="Select active view">
Pick the active tab (persisted in `localStorage` under `paca:active-view:{interactionKey}`).
</Step>
<Step title="Resolve filters">
Read `activeView.config.filters` and resolve to `sprint_ids`, `status_ids`, `assignee_ids`, and `task_type_ids`.
</Step>
<Step title="Fetch tasks">
Call `listAllTasks` (`GET /tasks`) with resolved filters. Column-grouped boards issue per-column queries when `column_by` is `status`, `sprint`, `assignee`, or `type`.
</Step>
<Step title="Apply manual order">
When `sort_by` is unset or `"manual"`, load positions from `GET /views/:viewId/task-positions` (or use `view_id` enrichment) and sort client-side.
</Step>
<Step title="Render layout">
Render Board, Table, Roadmap, or Plugin using the same task payload.
</Step>
</Steps>

Manual sort is active when `sort_by` is absent or equals `"manual"` (case-insensitive). Non-manual sorts use `sortTasksByConfig` on the client.

## Manual task ordering

Manual order uses fractional indexing with `DOUBLE PRECISION` positions in `view_task_positions`. Each row is unique per `(view_id, task_id)` and optionally records a `group_key` (the column/group value, such as a status UUID on a board).

### Position assignment

The web client computes positions in `handleReorderTask` (`interaction-layout.tsx`):

| Scenario | Formula |
|---|---|
| Between two tasks | `(prev + next) / 2` |
| Append after last | `(prev + POSITION_MAX) / 2` |
| Prepend before first | `next / 2` |
| No neighbours | `POSITION_MAX / 2` |

`POSITION_MAX = Number.MAX_SAFE_INTEGER` (2⁵³ − 1). Positions stay in `(0, POSITION_MAX)`.

Tasks with no recorded position (`view_position = null`) sort after all positioned tasks, ordered by `created_at` ascending. When a drag lands next to unpositioned tasks, the client assigns virtual positions and bulk-upserts all affected rows to materialize the order.

### Task-position endpoints

:::endpoint PUT /api/v1/projects/:projectId/views/:viewId/task-positions
Bulk upsert manual positions. Preferred for drag-and-drop because one drag may update multiple tasks.
:::

<RequestExample>

```json
{
  "items": [
    { "task_id": "uuid", "position": 98304.0, "group_key": "status-uuid" },
    { "task_id": "uuid", "position": 163840.0, "group_key": null }
  ]
}
```

</RequestExample>

:::endpoint PUT /api/v1/projects/:projectId/views/:viewId/task-positions/:taskId
Single-task upsert. Body: `{ "position": <float64>, "group_key": "<string|null>" }`.
:::

:::endpoint GET /api/v1/projects/:projectId/views/:viewId/task-positions
Returns `{ "items": [{ "view_id", "task_id", "position", "group_key" }] }`.
:::

<Note>
Renormalisation of collapsed float gaps is not automated. With float64 spacing, billions of insertions into the same gap are required before positions collide in practice.
</Note>

## Error codes

| Code | HTTP | When |
|---|---|---|
| `VIEW_IS_LAST_VIEW` | 409 | Deleting the only view in a context |
| `VIEW_REORDER_INVALID` | 400 | `view_ids` list does not match existing views |
| `invalid context` | 400 | `context` is not `sprint`, `backlog`, or `timeline` |
| `sprint_id is required` | 400 | Sprint context without `sprint_id` |

## Extending the model

When adding new interaction behavior:

- Extend `ViewConfig` and `ViewFilters` rather than adding context-specific route families.
- Express task scoping through `config.filters` and the shared `GET /tasks` endpoint.
- Keep `view_task_positions` as the source of truth for manual ordering metadata.
- Drive timeline behavior through `task_type_ids` filters, not dedicated timeline list endpoints.

<Warning>
Legacy handler methods `ListBacklogTasks` and `ListTimelineTasks` exist in the codebase but are not registered in the HTTP router. Integrations should use `GET /tasks` with the appropriate filter parameters.
</Warning>

## Related pages

<CardGroup>
<Card title="REST API reference" href="/rest-api">
Versioned paths, auth, pagination envelopes, and the full endpoint catalog.
</Card>
<Card title="Task activity API" href="/task-activity-api">
Activity feed and comment endpoints for tasks shown in views.
</Card>
<Card title="Plugin system" href="/plugin-system">
WASM sandbox, frontend extension points, and plugin view types.
</Card>
<Card title="Build a plugin" href="/build-plugin">
Author plugin views with `plugin_manifest_id` and `plugin_component` config.
</Card>
</CardGroup>
