# Summary page (`/`) — current-system spec (parity bar for the rebuild)

> The home page of the app: a month-scoped money-and-hours dashboard. It shows one headline
> card (this month's billout revenue [owner-only] or cost, total hours, and over/under a 140h
> monthly target) plus a sortable per-(operator, client, project) table comparing Prev month /
> This month / All time, each cell in `$X,XXX.XX (h:mm)` format with an owner-only billout line
> stacked on top. All aggregation happens in one Postgres RPC (`summary_by_project`); all filter
> state lives in the URL. Source of truth: `app/(app)/page.tsx`, `app/(app)/summary-table.tsx`,
> `lib/filters.ts`, `components/filter-bar.tsx`, migration `20260524000013_eliminate_proportion.sql`
> (latest `summary_by_project` definition), `20260525000011_invoice_status_filter.sql` (latest
> `entries_filter_options`).

## User workflows

- **"How did this month go?"** — rian opens the app, sees this month's adjusted billout
  (what he charges), the cost underneath (what he pays), total hours, and whether the team is
  over/under the ~140h/month Tingang target. This replaces the legacy Google Sheet's "summary"
  tab (transcript 2026-05-20: pick any month, default this month, per-project hours + overall,
  140h target over/under).
- **"Which projects ate the month?"** — the by-project table, default-sorted by This-month
  (billout for rian, cost for others), compares against last month and all time. Long-dormant
  projects are hidden so the table stays scannable.
- **"Show me just PlusROI / just Adi's people"** — pivot dropdowns (Operator / Client / User /
  Source email) narrow both the table and the headline card; the 140h tracker then tracks the
  filtered scope.
- **Month archaeology** — page backwards month-by-month (`← May 2026`) with filters preserved;
  jump back with "This month".
- **Adi's view** — same page without any billout numbers (cost + hours only), and when rian
  uses view-as-Adi the data is additionally scoped to imports Adi made.

## Complete behavior

### Month picker semantics (all logic local to `page.tsx`)

- **B1.** Route is `/` (inside the auth-gated `(app)` group). Server component
  `SummaryPage({ searchParams })`; `searchParams` is awaited (Next 15 style promise).
- **B2.** `parseYearMonth(sp.month)`: accepts regex `/^(\d{4})-(\d{1,2})$/` (both `2026-7` and
  `2026-07`). Falls back **silently to the current server-local year/month** (`new Date()`,
  `getMonth()+1`) when the param is absent, regex-mismatched, or month ∉ 1..12. Any 4-digit
  year is accepted (e.g. `0001-01` is "valid"). No error is ever shown for a bad month param.
- **B3.** Month boundaries are naive timestamp strings:
  `monthStart(y,m)` = `` `${y.padStart(4,'0')}-${m.padStart(2,'0')}-01 00:00:00` `` (space
  separator, no `T`, no timezone). `nextMonthStart` rolls December → January of year+1. The
  window is **half-open**: `[monthStart, nextMonthStart)`.
- **B4.** "Prev" month = `shiftMonth(current, -1)` (while-loop normalization, any delta works;
  January → December of year−1). Prev is always exactly current−1; not user-selectable
  independently.
- **B5.** `monthLabel(y,m)` = `new Date(y, m-1, 1).toLocaleDateString('en-US', { month:'long',
  year:'numeric' })` → e.g. `"July 2026"`. Used for the `<h1>` and the table column headers.
- **B6.** Month paging: exactly two links in `.page-head-actions` (class
  `button-link-secondary`):
  - `← {prev month label}` — builds a `URLSearchParams` from **all current string-valued search
    params** (pivot filters survive paging) and sets `month` to `YYYY-MM` (4-pad year, 2-pad
    month) of the previous month.
  - `This month` — preserves all params **except** `month` (deleting `month` means current
    month); href is bare `/` when no other params remain.
  - **There is no "next month →" link.** From a past month you can only go further back or jump
    to now. (Known wart; see Rebuild deltas.)

### Data fetch

- **B7.** `getAppOrg(supabase)` resolves the single app org by `slug = 'bowden-works'`
  (`APP_ORG_SLUG` in `lib/org.ts`). If null → page renders `<h1>Summary</h1>` +
  `<p class="error">Bowden Works organization not found.</p>` and nothing else.
- **B8.** View-as scoping: with `eu = getEffectiveUser(supabase, org.id)`,
  ```ts
  scopeToImportedBy = eu?.is_viewing_as && !eu.is_super_admin && eu.org_role !== 'owner'
    ? eu.effective_user_id : null
  ```
  i.e. only when impersonating a **non-owner, non-super-admin** user is the summary restricted
  to entries whose import batch was `imported_by` that user. Normal sessions (any role) and
  view-as-an-owner: no scoping. This implements the interim "Adi sees only what he uploaded"
  rule (transcript 2026-05-22) for the impersonated view; note Adi's own normal session is
  scoped by RLS, not by this parameter.
- **B9.** Two RPCs run in parallel (`Promise.all`):
  1. `summary_by_project(p_org_id, p_this_start, p_this_end, p_prev_start, p_prev_end,
     p_imported_by, p_operator, p_client, p_user, p_source_email)` — the aggregate. Bounds are
     the B3 strings; pivot params are `string | null`.
  2. `entries_filter_options(p_org_id, p_start: null, p_end: null, p_status: 'all',
     p_batch: null, p_q: null, p_project: null, p_user, p_source_email, p_client, p_operator,
     p_imported_by)` — dropdown options. **Deliberately no date window** (all time) so pivot
     values are pickable across history; current pivot values are passed in so the option lists
     narrow as you pick (same RPC the Entries page uses; returns one row of
     `{operators, clients, projects, users, source_emails}`, each `text[] | null`).
- **B10.** Filter pivots read from search params, each honored only if a non-empty trimmed
  string: `operator`, `client`, `user`, `source_email`. **`project` is intentionally not a
  pivot on this page** — the table already groups by project, filtering by it would just hide
  rows (code comment). The page does NOT use `readFiltersFromSearchParams`; it has its own
  month logic.
- **B11.** Only `projectsResult.error` is surfaced (rendered as
  `<p className="error">{message}</p>` above the headline). An `entries_filter_options` error
  is **silently swallowed** — the dropdowns just render empty.
- **B12.** Money columns can arrive from PostgREST as strings (Postgres `numeric`); every
  numeric field is coerced with `Number(x ?? 0)`.

### `summary_by_project` RPC semantics (migration `20260524000013_eliminate_proportion.sql`)

- **B13.** `language sql, stable, security invoker, set search_path = public` — RLS applies to
  the calling user (view-as via JWT swap therefore also changes what the RPC can see).
- **B14.** Base scan: `time_entries` where `org_id = p_org_id` **and `duration_seconds IS NOT
  NULL`** (entries without a duration are excluded from every column, including all-time).
- **B15.** Grouping key: `coalesce(te.operator, '(no operator)')`,
  `coalesce(te.client, '(no client)')`, `coalesce(te.project, '(no project)')` — one output row
  per distinct (operator, client, project) **text triple** (the denormalized cache columns, not
  entity FKs; this caused the "Halo Programs in summary but not manage" ghost).
- **B16.** Window classification is by **`start_at`** only, half-open:
  `this_seconds = sum(duration_seconds) where start_at >= p_this_start and start_at < p_this_end`
  (same for prev; `all_seconds` is unwindowed). Identical CASE pattern for
  `{this,prev,all}_cost_usd` (sum of `billout_cost_usd`) and `{this,prev,all}_billout_usd`
  (sum of `billout_amount_usd`). Post-ADR #034, "hours" = **source hours**
  (`duration_seconds`), and the dollar columns are **locked-at-write stamps** — no rate lookup
  happens at read time.
- **B17.** Pivot filters are ILIKE contains-matches:
  `te.operator ILIKE '%'||p_operator||'%'`, `te.client ILIKE …`, `te.converted_user ILIKE
  '%'||p_user||'%'`, `te.source_user_email ILIKE …` (each skipped when the param is null).
  Note the `user` pivot matches the **`converted_user`** column.
- **B18.** `p_imported_by` filter: `EXISTS (select 1 from clockify_imports ci where
  ci.id = te.import_id and ci.imported_by = p_imported_by)`.
- **B19.** Adjusted billout: the RPC left-joins the entity chain by case-insensitive name —
  `operators o ON o.org_id = p_org_id AND lower(o.name) = lower(agg.operator)` →
  `clients c ON c.operator_id = o.id AND lower(c.name) = lower(agg.client)` →
  `projects p ON p.client_id = c.id AND lower(p.name) = lower(agg.project)` — then per window:
  `X_billout_adj_usd = X_billout_usd * (1 + coalesce(p.billout_adjustment_pct,0)/100)
  + coalesce(p.billout_adjustment_amount,0)`.
  - If the text triple doesn't resolve to a `projects` row (placeholders, renamed entities),
    adjustments are 0 → adjusted == raw.
  - **Wart:** the fixed `billout_adjustment_amount` is added to **each** of this/prev/all
    independently, even to a window whose raw billout is $0 — a $50 fixed adjustment shows +$50
    in every window of that project row, every month, forever.
- **B20.** RPC output ordering is `ORDER BY this_seconds DESC` (the client re-sorts anyway).
  Return columns exactly: `operator, client, project, this_seconds, prev_seconds, all_seconds
  (bigint), this_cost_usd, prev_cost_usd, all_cost_usd, this_billout_usd, prev_billout_usd,
  all_billout_usd, this_billout_adj_usd, prev_billout_adj_usd, all_billout_adj_usd (numeric)`.

### Totals, row mapping, hide rule

- **B21.** Totals are computed **in JS** by reducing over **all** RPC rows (before the hide
  filter): 12 accumulators — `{this,prev,all}_seconds`, `_cost`, `_billout`, `_billout_adj`.
  Because hidden rows have prev=this=0, this only changes the **All time** total column —
  which is intended (all-time totals include dormant projects).
- **B22.** Row mapping to `ProjectRow`: camelCase, with billout preferring the adjusted value:
  `prevBillout = Number(r.prev_billout_adj_usd ?? r.prev_billout_usd ?? 0)` (same for
  this/all). The table's `totals` prop is passed the **adjusted** billout totals.
- **B23. Hide-null-rows rule:** after mapping, rows are filtered with
  `.filter((r) => r.prevSeconds > 0 || r.thisSeconds > 0)` — a project row with no activity in
  either the prev or this window is hidden **even if it has all-time activity**. (Origin:
  transcript 2026-05-21, "hide any summary row with null values in both prev and this month
  columns".)

### Headline card (`.panel.summary-headline`)

- **B24.** `canSeeBillout = canTransfer(eu)` gates the money display (same permission as
  invoice actions — see Permissions).
- **B25.** Owner view: a stacked column —
  - Big span (class `summary-cost`, 2.8rem, inline `color: '#0a7d3f'` green):
    `fmtUsd(totals.this_billout_adj)`. `title` tooltip:
    - if `totals.this_billout === totals.this_billout_adj`:
      `"Owner-only: what we charge the client for this month's billout hours."`
    - else: `` `Adjusted billout (raw ${fmtUsd(totals.this_billout)} after per-project adjustments).` ``
  - Muted 0.85em line: `{fmtUsd(totals.this_cost)} cost`, tooltip
    `"Cost (what we pay) for this month's billout hours."`
- **B26.** Non-owner view: a single `summary-cost` span = `fmtUsd(totals.this_cost)` (no green,
  no billout anywhere).
- **B27.** Always: `summary-hours` span = `` `${fmtHoursMinutes(totals.this_seconds)} hours` ``.
- **B28. 140h target tracker:** `TARGET_HOURS = 140` (hardcoded module constant).
  `overUnder = totals.this_seconds/3600 − 140`. Rendered as
  `` `{overUnder >= 0 ? '+' : ''}{overUnder.toFixed(2)}h vs 140h target` `` in a
  `summary-target` span with class `over-target` (when `overUnder >= 0`) or `under-target`.
  CSS: `.over-target { color:#15803d }` (green), `.under-target { color:#b45309 }` (amber).
  The tracker applies to the **current filter scope** — pivoting changes the tracked total.
- **B29.** Page `<h1>` = current month label; subtitle (exact copy): *"Billout cost and hours.
  Cost is locked to each entry's import-time base rate — changing a team rate later only
  affects new imports."* — user-facing documentation of the locked-stamp semantic.

### By-project table (`SummaryTable`, client component wrapping `SortableTable`)

- **B30.** Columns (exact keys / headers / sort accessors):

  | key | header | sort accessor | numeric |
  |---|---|---|---|
  | `operator` | `Operator` | `r.operator.toLowerCase()` | no |
  | `client` | `Client` | `r.client.toLowerCase()` | no |
  | `project` | `Project` | `r.project.toLowerCase()` | no |
  | `prev` | `Prev ({prev month label})` | `canSeeBillout ? r.prevBillout : r.prevCost` | yes |
  | `this` | `This ({this month label})` | `canSeeBillout ? r.thisBillout : r.thisCost` | yes |
  | `all` | `All time` | `canSeeBillout ? r.allBillout : r.allCost` | yes |

  Money columns sort by **billout when the viewer is an owner** (code comment: "revenue is the
  more useful axis for owners"), by cost otherwise.
- **B31.** Default sort: `defaultSortKey="this"`, `defaultDir="desc"`. `SortableTable` toggle
  semantics: clicking a new column sorts it **desc** first; clicking the active column flips
  asc/desc; active column shows ` ▲`/` ▼`; nulls sort last; numbers numeric, strings
  `localeCompare`. Header hint copy above the table: `<h2>By project</h2>` + muted
  *"Click any column header to re-sort."*
- **B32. Cell format:** `fmtCostAndHours(cost, seconds)` → `` `${fmtUsd(cost)} (${fmtHoursMinutes(seconds)})` ``
  e.g. `$1,234.56 (12:34)`; returns `'—'` (em dash) when both cost and seconds are falsy.
- **B33. Stacked owner cell** (`renderCell(cost, seconds, billout, canSeeBillout)`):
  - Non-owner → the plain B32 string.
  - Owner → if cost, seconds, AND billout are all 0 → `'—'`. Otherwise a right-aligned flex
    column (`lineHeight:1.15`): top line `fontWeight:600` = `fmtUsd(billout)` if
    `billout > 0`, else a muted `'—'` (cost-only rows keep the stack with a dash on top);
    bottom line muted `0.85em` = the B32 cost+hours string.
- **B34.** Footer: rendered only when `rows.length > 0`. `<tfoot>` row (`fontWeight:600`,
  `borderTop:'2px solid #e5e5e5'`): `<td colSpan={3}>Total</td>` then three `.num` cells via
  the same `renderCell` with the totals — so owners see the stacked billout/cost totals too.
- **B35.** `rowKey` = `` `${operator}|${client}|${project}` ``. Empty state:
  `<p className="empty">No entries yet. Try the Import page.</p>`.
- **B36.** Below the table, a footer panel cross-link (exact copy): *"Want the same view sliced
  by team member, source email, or project? Head to Entries and use the filter row at the top —
  the totals card mirrors whatever you filter."*

### Formatting helpers (`lib/format.ts`)

- **B37.** `fmtUsd(n)` → `n.toLocaleString('en-US', {style:'currency', currency:'USD',
  minimumFractionDigits:2, maximumFractionDigits:2})` → `$1,593.89`; null/undefined → `'—'`.
- **B38.** `fmtHoursMinutes(seconds)` → total minutes = `Math.round(seconds/60)`, then
  `` `${floor(min/60)}:${(min%60).padStart(2,'0')}` `` → `113:51` (hours are NOT capped at 24
  and NOT zero-padded; minutes rounded to nearest). null/undefined → `'—'`.

### FilterBar param contract (`components/filter-bar.tsx` — shared across pages)

- **B39.** The Summary page renders `<FilterBar showPivots hideDate hideProjectPivot showClear
  options={filterOptions} />` — no status/batch/query/importers; date select hidden because the
  month picker owns the window; project pivot hidden per B10.
- **B40.** FilterBar is fully URL-driven (`useSearchParams` read, `router.push` write inside
  `useTransition`; no local value state except free-text `defaultValue`). Complete set of
  params it manages: `date`, `start`, `end`, `status`, `batch`, `q`, `operator`, `client`,
  `project`, `user`, `source_email`, `imported_by`. Every update also **deletes `page`**
  (resets pagination). Patch semantics: value `null`/`''` → delete the key, else set.
- **B41.** Props (all optional): `showStatus`, `showBatch`, `showQuery`, `showPivots`,
  `hideProjectPivot`, `hideDate`, `batches: {id,label}[]`, `options: FilterOptions`
  (`{operators?, clients?, projects?, users?, source_emails?}` each `string[] | null`),
  `defaultDate: DatePreset = 'this-month'` (display-only; must match the page's server-side
  default), `defaultStatus: StatusFilter = 'all'`, `showClear`, `importers: {id,label}[] | null`.
- **B42.** `pivotSelect(label, paramKey, currentValue, optionList)` three-mode rendering:
  - `optionList === undefined` (dimension not provided): a free-text `type="search"` input,
    placeholder `"contains…"` (backend treats the param as ILIKE-contains).
  - otherwise: a `<select>` with first option `"All"` (value `''`) + the list; if the current
    URL value isn't in the (narrowed) list it is **prepended** so the selection stays visible.
- **B43.** Date select options (when not hidden): `this-month` "This month", `last-month`
  "Last month", `custom` "Custom range", `all` "All dates". Choosing any preset also clears
  `start`/`end`. `custom` reveals two `type="date"` inputs → `start`/`end` params.
- **B44.** Status select (when shown): values `all` / `pending` / `transferred` (label
  **"Applied to invoice"**) / `blocked`. The UI **never emits `'applied'`** even though
  `StatusFilter` includes it as the preferred post-invoice-migration alias — both values are
  treated identically downstream.
- **B45.** "Clear filters" button (`showClear`): `router.push(pathname)` — wipes **all** query
  params, **including `month`** on the Summary page, silently jumping back to the current
  month (known wart).
- **B46.** Free-text inputs (`q`, pivot fallback) push a router navigation on **every
  keystroke** — no debounce (known perf wart; each push is a full RSC re-render).

### `lib/filters.ts` — `readFiltersFromSearchParams(sp, defaults)` (NOT used by `/`, but part of the shared filter contract)

- **B47.** Returns `{startBound, endBound, status, batch, q, project, user, source_email,
  client, operator, imported_by}`. `date` = `sp.date ?? defaults.date ?? 'this-month'`;
  `status` = `sp.status ?? defaults.status ?? 'all'` (both cast, unvalidated). `batch` taken
  only if string AND not the literal `'all'`. `q` trimmed, `''` → null. The six pivot params
  go through `trimOrNull` (non-string → null; trimmed; empty → null).
- **B48. Exact date-boundary math** (all bounds naive `'YYYY-MM-DD HH:MM:SS'` strings; "now" is
  the **server's local clock**; ranges are half-open `[startBound, endBound)`):
  - `'this-month'`: start = 1st of current month `00:00:00`; end = 1st of next month
    (December → January year+1).
  - `'last-month'`: start = 1st of previous month (January → December year−1); end = 1st of
    current month.
  - `'custom'`: if `sp.start` present → `` `${start} 00:00:00` ``. If `sp.end` present →
    parsed as `new Date(end + 'T00:00:00Z')` (**UTC**), advanced one day via
    `setUTCDate(getUTCDate()+1)`, re-serialized from `getUTC*` parts as
    `'YYYY-MM-DD 00:00:00'` — i.e. the user's end date is **inclusive** (bound is midnight of
    the following day, exclusive). Either side may be absent (half-open allowed).
  - `'all'` and any unrecognized value: both bounds null.
  - Inconsistency: month presets use server-local time, custom-end math uses UTC — harmless for
    date-only strings but the mix only works because `time_entries.start_at` is stored naive.

## Data model

Read-only feature — no writes. Reads:

- **`time_entries`** (via the RPCs): `org_id`, `start_at` (naive timestamp — window
  classification), `duration_seconds` (source hours; NULL ⇒ excluded), `billout_cost_usd`
  (locked-at-write cost stamp, ADR #014/#034), `billout_amount_usd` (locked-at-write billout
  stamp), `operator` / `client` / `project` (denormalized **text** cache columns — the grouping
  key), `converted_user` (the `user` pivot matches this), `source_user_email`, `import_id`.
- **`clockify_imports`**: `id`, `imported_by` (the view-as scoping gate).
- **`projects`**: `billout_adjustment_pct`, `billout_adjustment_amount` (NULL ≡ 0; display-only
  per ADR #040), joined via **`operators`** (`org_id`, `name`) → **`clients`** (`operator_id`,
  `name`) → **`projects`** (`client_id`, `name`), all matched `lower(name)` against the entry
  text triple.
- **`organizations`**: resolved by `slug = 'bowden-works'` (`getAppOrg`); `id` feeds every RPC.
  (`default_hourly_rate_usd` / `FALLBACK_BASE_RATE = 14` in `lib/org.ts` are NOT used by
  current summary math — legacy of the pre-stamp era.)
- Supporting index (from `20260521140001_summary_rpc.sql`): partial index on
  `(org_id, project, start_at)` — predates the #034 rewrite; the RPC now scans on
  `duration_seconds IS NOT NULL`.
- Performance history (docs/features/summary.md): pre-RPC ~700ms at 3.3k rows via 7 paginated
  queries; post-RPC ~160ms, response size constant in entry count (~3KB for ~80 projects).

## Permissions

- **View the page:** any authenticated member of the app org (the `(app)` layout redirects
  no-user or no-effective-user to `/login`; RLS scopes what the `security invoker` RPCs can
  see).
- **See billout (headline top line + stacked cell line + billout-based sorting):**
  `canTransfer(eu)` from `lib/permissions.ts` — true iff
  `(eu.is_super_admin && !eu.is_viewing_as) || eu.org_role === 'owner'`. The same function
  gates invoice actions and the Invoices/Tools nav links; there is no separate
  "can see revenue" permission (known conflation).
- **View-as data scoping:** when a super admin impersonates a non-owner, `p_imported_by =
  eu.effective_user_id` is passed to both RPCs (B8), and `canSeeBillout` is false (since
  `is_viewing_as` is true and the impersonated role isn't owner) — the impersonated view shows
  cost-only, import-scoped data, matching what that user would see.

## Edge cases & error behavior

- Org not found → hardcoded error paragraph `"Bowden Works organization not found."`; nothing
  else renders (single-org assumption baked into copy).
- `summary_by_project` error → its `message` rendered in a `.error` paragraph; the page still
  renders with zeroed totals and an empty table.
- `entries_filter_options` error → **silently ignored**; dropdowns render with only the "All"
  option (and the currently-selected value, if any, via the B42 prepend).
- Malformed / out-of-range `?month=` → silent fallback to the current month (B2); no feedback.
- Entries with NULL `duration_seconds` are invisible to this page entirely (B14), even if they
  carry cost stamps.
- Entries with NULL operator/client/project group under the literal placeholder strings
  `(no operator)` / `(no client)` / `(no project)` (B15) and never match a `projects` row, so
  never receive adjustments.
- Text-vs-entity drift: because grouping is by text columns, a renamed entity (or an entry
  whose `project_id` is NULL but text is set) produces summary rows with no manage-page
  counterpart — the "Halo Programs" ghost rian flagged 2026-06-10.
- Fixed billout adjustment leaks into empty windows (B19 wart): a project with
  `billout_adjustment_amount = 50` and zero activity this month still contributes $50 to this
  month's adjusted billout total **if the row survives the hide rule via prev-month activity**;
  hidden rows still contribute their adjusted all-time to the All-time total.
- "Clear filters" also clears `month` (B45) — user viewing March loses their place.
- Totals include hidden rows (B21) — correct for All-time, and a no-op for prev/this.
- No pagination: the RPC returns every project group ever; fine at ~80 projects, unbounded
  growth is untested.
- Timezone: all month math is server-local against naive stored timestamps; correct only while
  server TZ and data TZ agree (they do today).

## UI notes

Current look: `.page-head` with `<h1>{Month Year}</h1>` + rate-locking subtitle; two secondary
link-buttons for month paging; big green ($ 2.8rem) headline panel; toolbar of pivot selects +
Clear button; zero-padding panel containing the table; muted cross-link footer panel. Inline
styles are used liberally (stack layout, footer border, green color) — this is acknowledged
debt.

From rian's taste profile (transcript extraction §17) as applied to this page:

- **Liked / asked for:** the single big headline stat with details in small print underneath
  ("the main stat [should be] the current cost and below that in the small print... total
  hours with (+/- hours)"); `$X.XX (hh:mm)` combined cells (his exact requested format);
  sortable **every** column; dropdown options constrained to what's actually in the data
  (options-RPC narrowing); sensible defaults (default month = this month, default sort = This
  desc); hiding dormant rows; color-as-state (green over / amber under target); owner-only
  bold-billout-over-muted-cost stacking (his spec verbatim: "bold: $2145.42 / small:
  $1,201.54 (85:49)" — Adi sees only the cost line).
- **Disliked (avoid in rebuild):** important info in small grey while noise dominates; overkill
  chrome/accordions; actions with no progress feedback; slow loading ("Why does it load
  slowly? I'll have at least 100x more data" — the complaint that forced the RPC rewrite);
  redundant/duplicated fields. Note the per-keystroke RSC round-trip (B46) and full server
  re-render per filter change are exactly the class of slowness he flagged.
- He explicitly did NOT want next-month or all-time in the top line (2026-05-21) — the top
  line's minimalism is intentional; the missing next-month *paging link* is not (it was
  dropped alongside the top-line cleanup and never restored).

## Rebuild deltas

Per `docs/rebuild/00-vision-and-scope.md`, `02-domain-model.md`, `04-architecture.md`,
`07-judgment-calls.md`:

- **RPC → service function.** `summary_by_project` semantics port to
  `services/reporting/` built on `scope()`-filtered queries (04-architecture "Reporting");
  this doc pins the semantics to reproduce (B13–B20 especially).
- **Text-triple grouping dies.** Denormalized `operator`/`client`/`project` columns are
  dropped (00-vision phase-outs; 02-domain-model §3) — grouping becomes FK-based, which
  eliminates B15's placeholders, the lower(name) join chain (B19), and the ghost-row class of
  bugs. `(no project)` becomes "unassigned project" semantics on a NULL `project_id`.
- **140h target becomes configuration** — a setting on the Tingang engagement, not a hardcode
  (07-judgment-calls #12; transcript flag #17).
- **`canTransfer`-as-revenue-gate splits** into named capabilities with identical R1 truth
  tables (02-domain-model §4 parity mapping mentions `canSeeIncome` as its own capability).
- **View-as scoping** (B8's `imported_by` stopgap) is replaced by the engagement-visibility
  engine; impersonation becomes an app-layer context swap (07-judgment-calls #7).
- **SPA + TanStack Query** replaces per-keystroke RSC reloads (B46) and full-page filter
  round-trips; FilterBar/MoneyCell/DurationCell become design-system primitives; zero inline
  styles (04-architecture frontend rules).
- **UX fixes to carry:** add next-month paging; "Clear filters" must not reset the month;
  surface options-RPC errors; keep filters-in-URL (bookmarkable filters are a real rian
  workflow — 04-architecture).
- **Money as decimals-as-strings** in the generated client kills the `number | string` union
  coercion (B12) — flagged as a real bug class in 04-architecture.
- **Timezone story made explicit** (org-local vs UTC) instead of B48's server-local/UTC mix.
- Adjustment display semantics (B19/B22, incl. the per-window fixed-amount wart) survive only
  until the R4 invoice engine replaces display-only adjustments (02-domain-model §5.5).

## Test specs

Carried IDs: SUM-001, SUM-002 (existing, 🟡 unautomated), FMT-001…FMT-008 (✅ existing, cover
`lib/format.ts`). New IDs below seed `06-test-plan.md`.

- T-SUM-001 (=SUM-001): given entries across two months and several projects / when
  `summary_by_project` runs with this/prev windows / then one row per (operator, client,
  project) with correct this/prev/all seconds (B14–B16).
- T-SUM-002 (=SUM-002): given entries with stamped `billout_cost_usd` / when the RPC runs /
  then dollar columns equal the sums of the stamps — no rate lookup at read time (B16).
- T-SUM-003: given `?month=2026-7` and `?month=2026-07` / when parsed / then both resolve to
  July 2026; absent, malformed, or month-13 params fall back to the current month (B2).
- T-SUM-004: given current month July 2026 / when bounds are built / then this =
  `['2026-07-01 00:00:00','2026-08-01 00:00:00')`, prev = June; December rolls to January
  year+1 (B3–B4).
- T-SUM-005: given active pivot filters and `?month=` set / when clicking "← prev" / then all
  pivot params survive and `month` decrements; "This month" drops only `month` (B6).
- T-SUM-006: given a project with prev=0 and this=0 but all-time>0 / when the page renders /
  then the row is hidden AND its all-time cost/billout still appear in the footer All-time
  total (B21, B23).
- T-SUM-007: given an entry with NULL `duration_seconds` / when the RPC runs / then it appears
  in no window and no total (B14).
- T-SUM-008: given entries with NULL operator/client/project / when the RPC runs / then they
  group under `(no operator)`/`(no client)`/`(no project)` with zero adjustments (B15, B19).
- T-SUM-009: given `projects.billout_adjustment_pct = -10`, `_amount = 50` on a matched
  project / when the RPC runs / then each window's `*_billout_adj_usd` =
  `raw × 0.9 + 50` — including windows whose raw is 0 (B19 + wart).
- T-SUM-010: given an entry whose (operator, client, project) text differs from any entity
  chain only by case / when the RPC runs / then the adjustment join still matches
  (lower(name) chain, B19).
- T-SUM-011: given owner (`canTransfer` true) / when the headline renders / then green
  adjusted-billout top line + muted "$X cost" line; tooltip switches between the raw-equal and
  adjusted variants (B25); non-owner sees a single cost span (B26).
- T-SUM-012: given this-month seconds = 342,000 (95h) / when the tracker renders / then
  `-45.00h vs 140h target` with class `under-target`; 522,000s (145h) → `+5.00h` with
  `over-target` (B28).
- T-SUM-013: given a pivot filter that narrows results / when the page renders / then the
  headline, tracker, AND table all reflect the filtered scope (B9, B28).
- T-SUM-014: given owner view / when sorting the `this` column / then rows order by
  `thisBillout`; non-owner orders by `thisCost`; default sort is `this` desc (B30–B31).
- T-SUM-015: given cost=1234.56, seconds=45,240 / when a cell renders / then
  `$1,234.56 (12:34)`; 0/0 → `—`; owner cell with billout=0 but cost>0 → muted `—` stacked
  over the cost line (B32–B33).
- T-SUM-016: given rows exist / when the table renders / then a Total footer row shows the
  same renderCell treatment over totals; zero rows → no footer, empty-state message (B34–B35).
- T-SUM-017: given view-as a non-owner / when the page loads / then both RPCs receive
  `p_imported_by = effective_user_id` and billout is hidden; view-as an owner or a normal
  owner session passes null (B8, Permissions).
- T-SUM-018: given the options RPC errors / when the page renders / then no error is shown and
  pivot dropdowns contain only "All" (+ any current selection); given the summary RPC errors /
  then its message renders in `.error` (B11, edge cases).
- T-SUM-019: given a selected pivot value absent from the narrowed option list / when the
  dropdown renders / then the value is prepended and stays selected (B42).
- T-FLT-001: given no `date` param and defaults `{}` / when `readFiltersFromSearchParams`
  runs / then preset `this-month` bounds per B48; `last-month` January → December year−1.
- T-FLT-002: given `date=custom&start=2026-03-05&end=2026-03-10` / then
  `startBound='2026-03-05 00:00:00'`, `endBound='2026-03-11 00:00:00'` (inclusive end, +1 day
  in UTC); month-end `end=2026-03-31` → `'2026-04-01 00:00:00'` (B48).
- T-FLT-003: given `date=all` or an unknown preset / then both bounds null; `batch=all` →
  null; whitespace-only pivot params → null (B47–B48).
- T-FLT-004: given any FilterBar update / then the `page` param is deleted and empty values
  remove their keys from the URL (B40).
- T-FLT-005: given "Clear filters" on `/?month=2026-03&operator=PlusROI` / then the URL becomes
  `/` — documents the month-reset wart; the rebuild should assert month is PRESERVED instead
  (B45 + Rebuild deltas).
- T-FMT-001 (=FMT-*): `fmtUsd(1593.89)` → `$1,593.89`; `fmtHoursMinutes(409,860s)` → `113:51`;
  minutes round to nearest; null → `—` (B37–B38).
