# Objects Data-Overview Table + Responsive Shell — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.

**Goal:** Turn `/objects` into a full-width, sortable, filterable data table (backed by Postgres sort/filter + exposed timestamps), with a collapsible icon sidebar and a responsive detail pane/drawer at a canonical `/objects/:id` URL.

**Architecture:** Phase 1 adds backend `sort`/`order`/`visibility`/`q` params (injection-safe) + a filtered count + exposes `created_at`/`updated_at`. Phase 2 replaces the narrow `ObjectList` with a full-width `ObjectsTable` whose state lives in the URL. Phase 3 makes the shell sidebar collapsible (lucide icons + Base UI tooltip) and renders detail as a right pane (wide) / Base UI `Drawer` (narrow) via the existing nested `/objects/:id` route.

**Tech Stack:** Rust (axum, sqlx/Postgres, utoipa), React 19 + TS + pnpm, `@base-ui/react` (drawer/collapsible/tooltip — already a dep), `lucide-react` 1.17 (already a dep), react-router 7, TanStack Query, Vitest+RTL+MSW, Storybook 10.

**Conventions:** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets -- -D warnings`; tests via `cargo nextest run`; pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source double-quote+semicolon, stories single-quote+no-semicolon; en/sv parity for new keys; **no codename**; portal queries in tests via `within(document.body)`; `pnpm check:size` budget **165 KB gz**. Test infra: Postgres 5442, Meili 7700; `#[sqlx::test(migrations="../db/migrations")]`.

**Spec:** `docs/superpowers/specs/2026-06-06-objects-table-and-shell-design.md`

---

## File Structure
**Backend:** `crates/db/src/catalog.rs` (filtered list+count, sort enum), `crates/api/src/admin_objects.rs` (query params, `AdminObjectView` timestamps), `crates/api/src/openapi.rs` (if new schema types). **Frontend:** `web/src/api/queries.ts` (`useObjectsPage` params), new `web/src/objects/objects-table.tsx` (+ `.stories.tsx`, `.test.tsx`), `web/src/objects/objects-page.tsx` (restructure to table + responsive detail), `web/src/shell/app-shell.tsx` (collapsible sidebar), new `web/src/components/ui/tooltip.tsx`, new `web/src/lib/use-media-query.ts`, `web/src/i18n/{en,sv}.json`. `web/src/objects/object-list.tsx` is removed (replaced by the table).

---

# PHASE 1 — Backend

## Task 1: Expose `created_at` / `updated_at` on `AdminObjectView`
**Files:** `crates/api/src/admin_objects.rs`; test `crates/api/tests/admin_catalog.rs`.

The domain `CatalogueObject` already carries `created_at`/`updated_at` (`time::OffsetDateTime`); only the API view omits them. No migration.

- [ ] **Step 1: Failing API test** in `admin_catalog.rs`: create an object, `GET /api/admin/objects`, assert the item has non-empty `created_at` and `updated_at` (RFC3339 strings). Run → fails (fields absent).
- [ ] **Step 2: Add fields.** In `AdminObjectView` add:
```rust
    /// RFC3339 UTC timestamp.
    pub created_at: String,
    /// RFC3339 UTC timestamp.
    pub updated_at: String,
```
In `from_object`, map them (the file already has a `format_date` for the `DATE`; for timestamps use RFC3339):
```rust
    created_at: o.created_at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),
    updated_at: o.updated_at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),
```
(Confirm `time` is a dep of the `api` crate; it is used transitively — if not in `Cargo.toml`, add `time.workspace = true`. Verify the `CatalogueObject` field names `created_at`/`updated_at` and their `OffsetDateTime` type in `crates/db/src/catalog.rs:210-211`.)
- [ ] **Step 3:** `cargo +nightly fmt`; `cargo clippy -p api`; run the test (compose up):
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo nextest run -p api -E 'test(admin_catalog)'
```
- [ ] **Step 4: Commit** `feat(api): expose object created_at/updated_at in AdminObjectView (#44)`.

## Task 2: Server-side sort / order / visibility / quick-filter for the object list
**Files:** `crates/db/src/catalog.rs`, `crates/api/src/admin_objects.rs`; tests in `crates/db/tests/object.rs` (or wherever catalog list is tested) + `crates/api/tests/admin_catalog.rs`.

- [ ] **Step 1: Define a sort enum + filtered db functions** in `crates/db/src/catalog.rs`. Add:
```rust
/// Whitelisted, injection-safe sort columns for the object list.
#[derive(Debug, Clone, Copy)]
pub enum ObjectSort { ObjectNumber, ObjectName, UpdatedAt, CreatedAt, Visibility }

impl ObjectSort {
    fn column(self) -> &'static str {
        match self {
            ObjectSort::ObjectNumber => "object_number",
            ObjectSort::ObjectName => "object_name",
            ObjectSort::UpdatedAt => "updated_at",
            ObjectSort::CreatedAt => "created_at",
            ObjectSort::Visibility => "visibility",
        }
    }
}

/// Filters + ordering for a paged object query. `visibility`/`q` are optional.
pub struct ObjectQuery<'a> {
    pub sort: ObjectSort,
    pub descending: bool,
    pub visibility: Option<&'a str>,
    pub q: Option<&'a str>,
}
```
Add `list_objects_query` + `count_objects_query` that build SQL from the **enum** (never a raw client string). Both share a WHERE builder. Example:
```rust
fn where_clause(visibility: Option<&str>, q: Option<&str>) -> (String, Vec<String>) {
    let mut clauses = Vec::new();
    let mut binds = Vec::new();
    if let Some(v) = visibility { binds.push(v.to_owned()); clauses.push(format!("visibility = ${}", binds.len())); }
    if let Some(term) = q {
        binds.push(format!("%{term}%"));
        let p = binds.len();
        clauses.push(format!("(object_number ILIKE ${p} OR object_name ILIKE ${p})"));
    }
    let sql = if clauses.is_empty() { String::new() } else { format!(" WHERE {}", clauses.join(" AND ")) };
    (sql, binds)
}

pub async fn list_objects_query(
    pool: &sqlx::PgPool, query: &ObjectQuery<'_>, limit: i64, offset: i64,
) -> Result<Vec<CatalogueObject>, sqlx::Error> {
    let (where_sql, binds) = where_clause(query.visibility, query.q);
    let dir = if query.descending { "DESC" } else { "ASC" };
    // Secondary key keeps ordering stable when the primary sort has ties.
    let sql = format!(
        "SELECT {OBJECT_COLUMNS} FROM object{where_sql} ORDER BY {} {dir}, object_number ASC LIMIT ${} OFFSET ${}",
        query.sort.column(), binds.len() + 1, binds.len() + 2,
    );
    let mut q = sqlx::query(&sql);
    for b in &binds { q = q.bind(b); }
    let rows = q.bind(limit).bind(offset).fetch_all(pool).await?;
    rows.into_iter().map(map_object).collect()
}

pub async fn count_objects_query(
    pool: &sqlx::PgPool, visibility: Option<&str>, q: Option<&str>,
) -> Result<i64, sqlx::Error> {
    let (where_sql, binds) = where_clause(visibility, q);
    let sql = format!("SELECT count(*) AS n FROM object{where_sql}");
    let mut query = sqlx::query(&sql);
    for b in &binds { query = query.bind(b); }
    query.fetch_one(pool).await?.try_get("n")
}
```
Keep the existing `list_objects_paged`/`count_objects` if other callers use them (grep; if only the handler calls them, you may replace — verify). The `ObjectColumns`/`map_object` already include the timestamp columns.
- [ ] **Step 2: db tests** in the catalog test file: seed objects with distinct names/visibilities; assert `list_objects_query` orders by `object_name DESC`, filters by `visibility="draft"`, and `q` ILIKE matches number/name; `count_objects_query` returns the filtered count.
- [ ] **Step 3: Handler query params.** In `admin_objects.rs`, add a deserialize struct (don't overload the shared `Pagination`):
```rust
#[derive(Deserialize)]
pub(crate) struct ObjectListParams {
    pub limit: Option<i64>, pub offset: Option<i64>,
    pub sort: Option<String>, pub order: Option<String>,
    pub visibility: Option<String>, pub q: Option<String>,
}
```
Parse `sort` → `ObjectSort` (unknown → default `ObjectNumber`), `order` → `descending = order == "desc"`, clamp limit (1..=200, default 50) / offset (>=0) like `Pagination`. Validate `visibility` against `domain::Visibility` (unknown → 422 or ignore — pick ignore-with-default for resilience to hand-edited URLs). Build `ObjectQuery`, call `list_objects_query` + `count_objects_query`. Update the `#[utoipa::path]` `params(...)` to document `sort`/`order`/`visibility`/`q`.
- [ ] **Step 4: API test** — `GET /api/admin/objects?sort=object_name&order=desc&visibility=draft&q=foo` returns filtered+sorted items and a matching `total`; no params → unchanged default (object_number asc).
- [ ] **Step 5:** fmt + clippy + `cargo nextest run -p api -p db`. **Commit** `feat: object list sort/filter/quick-search (server-side, injection-safe) (#44)`.

## Task 3: Regenerate web API types
- [ ] Start the built server on an alt port (8080 may be taken): `BIND_ADDR=127.0.0.1:8090 DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… ./target/debug/server`, then `cd web && pnpm exec openapi-typescript http://localhost:8090/api-docs/openapi.json -o src/api/schema.d.ts`. Verify `created_at`/`updated_at` appear on `AdminObjectView`; `pnpm typecheck`. Stop the server. **Commit** `chore(web): regenerate API types (object list params + timestamps)`.

---

# PHASE 2 — The table

## Task 4: `useObjectsPage` gains sort/filter params
**Files:** `web/src/api/queries.ts`.
- [ ] Replace the `(limit, offset)` signature with a params object and `keepPreviousData`:
```ts
import { keepPreviousData } from "@tanstack/react-query";

export type ObjectListParams = {
  limit: number; offset: number;
  sort?: string; order?: "asc" | "desc";
  visibility?: string; q?: string;
};

export function useObjectsPage(params: ObjectListParams) {
  return useQuery({
    queryKey: ["objects", params],
    placeholderData: keepPreviousData,
    queryFn: async () => {
      const { data, error } = await api.GET("/api/admin/objects", {
        params: { query: {
          limit: params.limit, offset: params.offset,
          sort: params.sort, order: params.order,
          visibility: params.visibility, q: params.q,
        } },
      });
      if (error || !data) throw new Error("failed to load objects");
      return data;
    },
  });
}
```
(openapi-fetch drops `undefined` query params, so omit-by-undefined is fine.) Update the existing call site in `object-list.tsx` — but that file is being replaced in Task 5; if Task 5 lands in the same branch, just ensure typecheck passes after Task 5. **Commit with Task 5** (or standalone if you prefer). Keep `useObject` unchanged.

## Task 5: `ObjectsTable` — full-width table, URL-synced state, pagination, sort headers
**Files:** create `web/src/objects/objects-table.tsx`, `objects-table.stories.tsx`, `objects-table.test.tsx`; delete `web/src/objects/object-list.tsx`.

Behavior: reads all state from the URL (`useSearchParams`) — `sort`, `order`, `q`, `visibility`, `offset`, `limit` (default sort `object_number`/`asc`, limit 50, offset 0). Renders a real `<table>`; reuses `VisibilityBadge`; columns № / Name / Visibility / Location / # / Updated; sortable headers toggle sort+dir (with `aria-sort`); a row is a `<tr>` whose click navigates to `/objects/:id` **preserving the current search string** (so back restores state); pagination footer with prev/next + page-size `<select>` (or the future `ui/select`); a debounced quick-filter `Input` (`q`) and visibility chips live in a toolbar (Task 6 may own the toolbar — implement them here together to keep the table coherent).

- [ ] **Step 1: Component.** Concrete core (fill routine markup/classes to match the app; use token classes per #49 where easy, else existing patterns):
```tsx
import { useSearchParams, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useObjectsPage } from "../api/queries";
import { useDebouncedValue } from "../lib/use-debounced-value";
import { VisibilityBadge } from "./visibility-badge";
// + ui/button, ui/input, ui/skeleton, lucide chevrons

const SORTABLE = ["object_number", "object_name", "updated_at"] as const;
const PAGE_SIZES = [25, 50, 100, 200];
const VIS = ["all", "draft", "internal", "public"] as const;

export function ObjectsTable() {
  const { t, i18n } = useTranslation();
  const navigate = useNavigate();
  const { id: selectedId } = useParams(); // highlight the open row
  const [params, setParams] = useSearchParams();

  const sort = params.get("sort") ?? "object_number";
  const order = (params.get("order") === "desc" ? "desc" : "asc") as "asc" | "desc";
  const visibility = params.get("visibility") ?? "all";
  const limit = Number(params.get("limit")) || 50;
  const offset = Number(params.get("offset")) || 0;
  const qParam = params.get("q") ?? "";
  const [qText, setQText] = useState(qParam);
  const q = useDebouncedValue(qText, 300);

  // sync debounced q → URL (reset offset)
  useEffect(() => {
    setParams((prev) => {
      const next = new URLSearchParams(prev);
      const term = q.trim();
      if (term) next.set("q", term); else next.delete("q");
      next.delete("offset");
      return next;
    }, { replace: true });
  }, [q, setParams]);

  const { data, isLoading, isError } = useObjectsPage({
    limit, offset, sort, order,
    visibility: visibility === "all" ? undefined : visibility,
    q: q.trim() || undefined,
  });

  const setParam = (mutate: (n: URLSearchParams) => void) =>
    setParams((prev) => { const n = new URLSearchParams(prev); mutate(n); return n; }, { replace: true });

  const toggleSort = (col: string) =>
    setParam((n) => {
      const curOrder = n.get("order") === "desc" ? "desc" : "asc";
      const curSort = n.get("sort") ?? "object_number";
      const nextOrder = curSort === col && curOrder === "asc" ? "desc" : "asc";
      n.set("sort", col); n.set("order", nextOrder); n.delete("offset");
    });

  // header cell: aria-sort = col===sort ? (order==='asc'?'ascending':'descending') : 'none'
  // row: <tr onClick={() => navigate(`/objects/${o.id}?${params}`)} aria-selected={o.id===selectedId} ...>
  // pagination: prev disabled offset===0; next disabled offset+limit>=total; page-size select sets limit + deletes offset
  // ...
}
```
Render loading via `Skeleton` rows; error → `objects.loadError`; empty → `objects.empty`. Visibility chips mirror the search-panel `<button aria-pressed>` pattern (set `visibility` param, delete `offset`). The "Updated" cell: format `o.updated_at` with `Intl.DateTimeFormat(i18n.language, { dateStyle:'medium', timeZone: useConfig().default_timezone })` (or a relative-time helper) — keep it a small local helper. **No `any`** (cast page items as `components["schemas"]["AdminObjectView"]`).
- [ ] **Step 2: i18n** — add `objects.columns.{number,name,visibility,location,count,updated}`, `objects.filter` (quick-filter placeholder), `objects.pageSize`, `objects.all` (or reuse `search.all`) to **both** `en.json` and `sv.json`.
- [ ] **Step 3: Stories** `objects-table.stories.tsx` — render inside a `MemoryRouter` (the preview provides providers; add a router if needed) with MSW returning a small page: `Default` (rows render), `Sorted` (assert `aria-sort` on the active header), `Empty`. Mirror the visibility-badge story format.
- [ ] **Step 4: Unit test** `objects-table.test.tsx` (RTL + MSW + MemoryRouter): rows render the columns; clicking a sortable header updates the URL `sort`/`order` and sets `aria-sort`; typing in the filter (debounced) sets `q`; a visibility chip sets `visibility`; pagination next/prev change `offset`; page-size sets `limit`. Use the search-panel test as a reference for MSW + router wiring.
- [ ] **Step 5:** `pnpm typecheck && pnpm lint && pnpm test -- objects-table`. **Commit** `feat(web): full-width sortable/filterable objects table with URL state (#44)`.

## Task 6: Wire the table into the page (table full-width; detail via Outlet placeholder)
**Files:** `web/src/objects/objects-page.tsx` (interim — full restructure in Phase 3).
- [ ] Make `ObjectsPage` render `ObjectsTable` full-width for now, keeping the nested `<Outlet/>` available but not as a fixed 20rem column (Phase 3 makes it a pane/drawer). Interim acceptable state: table fills the area; if a `:id` child route is active, render the detail below/over as a simple panel (Phase 3 makes it responsive). Remove the `index → SelectPrompt` route's visual prominence (the table is the landing view). **Verify** `pnpm test && pnpm build`. **Commit** `feat(web): objects table as the /objects landing view (#44)`.

> Note: Tasks 5–6 can be one commit if cleaner. The key is the table renders at `/objects` and row-click deep-links to `/objects/:id` with preserved query state.

---

# PHASE 3 — Shell & responsive detail

## Task 7: `useMediaQuery` hook + `ui/tooltip.tsx` wrapper
**Files:** create `web/src/lib/use-media-query.ts`, `web/src/components/ui/tooltip.tsx`.
- [ ] **`use-media-query.ts`** (tiny, SSR-safe, mirrors `use-debounced-value`):
```ts
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() =>
    typeof window !== "undefined" ? window.matchMedia(query).matches : false);
  useEffect(() => {
    const mql = window.matchMedia(query);
    const on = () => setMatches(mql.matches);
    on(); mql.addEventListener("change", on);
    return () => mql.removeEventListener("change", on);
  }, [query]);
  return matches;
}
```
- [ ] **`ui/tooltip.tsx`** — wrap `@base-ui/react/tooltip` parts (Provider/Root/Trigger/Portal/Positioner/Popup) in the established `ui/*` style (mirror `ui/alert-dialog.tsx`: `data-slot`, `cn`, `render=` where a trigger delegates). Export a simple `<Tooltip content=…>{trigger}</Tooltip>` convenience plus the raw parts. **RUN a quick story/test** to confirm the Base UI composition (first tooltip in the repo — verify the part tree by running, like the combobox was). No `any`.
- [ ] Typecheck/lint. **Commit** `feat(web): useMediaQuery hook + Base UI tooltip wrapper (#44)`.

## Task 8: Collapsible icon sidebar
**Files:** `web/src/shell/app-shell.tsx` (+ optional `sidebar.stories.tsx`).
- [ ] Add lucide icons to each nav item (e.g. `Boxes`/`BookMarked`/`Users`/`Search`/`Tags` — pick sensible icons). Add a collapse toggle button; persist `collapsed` to `localStorage` (`sidebar-collapsed`); auto-collapse when `useMediaQuery("(max-width: 768px)")`. Expanded: icon + label (`w-44`). Collapsed: icon only (`~w-14`) with the label via the `ui/tooltip` (and `aria-label`/`title`). Preserve `NavLink` active styling; add `focus-visible` rings.
- [ ] **Story** `app-shell` sidebar or a extracted `Sidebar` component: `Expanded` / `Collapsed` (assert labels hidden + tooltips/`aria-label` present). If extracting a `Sidebar` component from `app-shell` makes it testable/storyable, do so (keep `app-shell` thin).
- [ ] Typecheck/lint/test. **Commit** `feat(web): collapsible icon sidebar (persisted, auto-collapse on narrow) (#44, #58)`.

## Task 9: Responsive detail — right pane (wide) / Drawer (narrow) at canonical `/objects/:id`
**Files:** `web/src/objects/objects-page.tsx`; possibly a small `object-detail-panel.tsx`.
- [ ] Restructure `ObjectsPage`: always render `ObjectsTable`; detect an active detail child with `useMatch("/objects/:id")` / `useMatch("/objects/:id/edit")`. When matched:
  - **Wide** (`useMediaQuery("(min-width: 1024px)")`): render a right-hand pane (e.g. `grid-cols-[1fr_28rem]` when open, else `1fr`) containing `<Outlet/>`, with a close control (`navigate("/objects?"+params)`).
  - **Narrow:** render `<Outlet/>` inside a Base UI `Drawer` (`swipeDirection="right"`, edge = right) over the table; closing the drawer navigates back to `/objects` (preserve query). **RUN to confirm** the Drawer part tree (Root/Portal/Backdrop/Popup/Close) — first Drawer in the repo; mirror the alert-dialog wrapper conventions.
  - Remove the `index → SelectPrompt` route (the table is the landing view); `SelectPrompt` can be deleted if now unused (grep — it may also be used elsewhere; only remove if exclusively the objects index).
  - `:id/edit` continues to render through the same `<Outlet/>` (pane/drawer), preserving today's "edit in the right area" behavior.
- [ ] **Test:** with a mocked `matchMedia`, `/objects/:id` renders detail in a pane (wide) and in a portaled Drawer (narrow, query via `within(document.body)`); closing returns to `/objects` with the table's query string intact; deep-linking `/objects/:id` directly renders table + open detail.
- [ ] Typecheck/lint/test/build. **Commit** `feat(web): responsive object detail (pane/drawer) at canonical /objects/:id (#44, #58)`.

---

# PHASE 4 — Verification

## Task 10: Final verification
- [ ] Backend: `cargo +nightly fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… cargo nextest run --workspace` (single clean run — don't run two concurrently; sqlx temp-DB contention produces fake failures).
- [ ] Web: `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (index ≤ **165 KB gz** — lucide/tooltip/drawer land in the always-loaded shell; tree-shaken — verify and report the number).
- [ ] `pnpm test -- i18n` (en/sv parity for the new `objects.columns.*` etc.); `git grep -in 'biggus\|dickus' -- crates web/src || echo CLEAN`; `git status --short` clean.

---

## Self-Review (completed)
**Spec coverage:** sort/filter/q + filtered total + timestamps (T1–T3); full-width table with columns/sort/filter/pagination/URL-state (T4–T6); collapsible icon sidebar (T8); responsive pane/drawer + canonical `/objects/:id` (T7,T9); stories (T5,T7,T8); bundle/parity/codename (T10). ✓ Out of scope (Meili unification, detail-content #45, multi-select) not included. ✓
**Placeholder scan:** load-bearing logic (SQL builder, sort enum, URL-state wiring, sort toggle, responsive routing, media-query/tooltip) is concrete; routine table markup/classes are described to match existing patterns; the two novel Base UI primitives (Tooltip, Drawer) carry explicit "verify the part tree by running" steps (same approach that worked for the combobox), with canonical trees from the spec. No "TBD"/"add error handling".
**Type consistency:** `ObjectSort` enum + `ObjectQuery` (db) ↔ `ObjectListParams` (api) ↔ `useObjectsPage(ObjectListParams)` (web) align on sort/order/visibility/q; `AdminObjectView` gains `created_at`/`updated_at` (T1) consumed by the table's Updated column (T5). URL param names (`sort`/`order`/`visibility`/`q`/`limit`/`offset`) consistent across table read/write and the hook.

## Notes
- `lucide-react` + Base UI tooltip/drawer/collapsible are already deps → no `pnpm-lock` churn.
- No DB migration (timestamps already exist).
- Watch the bundle: icons/tooltip/drawer are in the always-loaded shell, not a lazy chunk — if `check:size` exceeds 165, lazy-import the Drawer (only used at narrow widths) or trim.
