Skip to content

feat: an agency roster of two or more clients opens as a dense table (#691 phase 3) - #696

Open
hyoshi wants to merge 1 commit into
mainfrom
feat/691-agency-table
Open

feat: an agency roster of two or more clients opens as a dense table (#691 phase 3)#696
hyoshi wants to merge 1 commit into
mainfrom
feat/691-agency-table

Conversation

@hyoshi

@hyoshi hyoshi commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

What

Phase 3 of #691: an agency roster of two or more visible clients opens as a dense, status-sorted table. Mockup screen 1b is the reference.

A card grid answers "how is this client doing?" one client at a time. Past a handful of clients the question becomes "which of these do I open first?", and a grid answers that badly — the figures sit in a different place on every card, so comparing two is a search rather than a glance.

Single-workspace (OSS) installs are untouched: they open a client's detail directly and never see a roster.

The table

  • 44px rows, one metric per column: status dot + name, spend, CPA, conversions, CTR, last updated, and a detail control.
  • Status order by default — needs-action → watch → clear, biggest spender first inside each group. The ranking is reports_triage.js's own; the table forms no second opinion about health or about any figure, and every number comes from the same aggregateClientKpis the cards use.
  • Any header re-sorts; clicking the same one again flips direction. Only the sorted column is marked, and the direction is a character (▲/▼) as well as weight, so it survives for anyone the colour does not reach. aria-sort is set.
  • A figure mureo will not state is "—" and sorts to the END in both directions. Unknown is not a small number; floating it to the top of an ascending sort would put the least informative rows where the most urgent belong.
  • A CPA that cannot be divided (no conversions) says "not calculable" rather than printing a zero.
  • Needs-action rows carry the same red left rule their card does.

The totals row

Spend and conversions are sums. CPA is the weighted average — total spend over total conversions — not the mean of the per-client CPAs, which would let a client spending ¥12,000 pull the roster figure as hard as one spending ¥128,000. A client whose figures are withheld is left out of the sums entirely rather than counted as zero. CTR is "—": averaging CTRs across clients with different impression volumes states a number nobody measured.

The toggle, remembered

The cards stay as a toggle and the choice persists per browser (localStorage, like the alert list's dismissals) — it is a preference about reading, not a fact about the data, so it never goes on the wire. A remembered "table" does not resurrect one for a roster that has since shrunk to a single client.

One filter, both views

applyReportsHealthFilter now iterates every roster row rather than the card grid's children, so one implementation hides both views. It applies the client-name search too, and that is deliberate rather than lazy: hidden on these rows has exactly one owner. Two functions both writing it would each undo the other depending on which ran last — the bug shape #665 is about. The filter and search compose; neither overrides.

Covered with the cascade resolver: a hidden row is asserted to compute display: none against the real app.css, not merely to carry the attribute.

⚠️ The CPA-vs-target column is not rendered — please read

It is in the approved design. Nothing on the reports wire carries a target CPA: there is no field on the summary payload, and STRATEGY.md's guardrails reach no web payload. I checked before building rather than assuming:

$ grep -n 'target\|goal' mureo/web/reports.py mureo/web/report_document.py
(none)

Your spec said "don't show the bar for a client with no target in STRATEGY — don't invent one". Applied to reality that means every client, so a rendered column would be "—" the whole way down: a column advertising a comparison mureo cannot make.

So the column is omitted when no client has a target, and the renderer for it ships and is tested against a feed that has one. The day a target reaches the summary the column appears with no further work. This is the same shape phase 4 has with #690 — the view is ready and the datum is the dependency.

If you want the column visible now, the data has to land first. Worth its own issue, parallel to #690.

New module

dashboard_reports_table.js (592 lines). dashboard_reports.js was already at 769, so the table would have pushed it past the 800 line ceiling from #687; the toolbar and view-switching live with the table they drive rather than in the index. Load order is state → report → overview → cards → triage → table → index, and every file stays under 800.

Test changes

tests/js/reports_filter_interaction.test.js now switches to the cards explicitly. That file is about the grid's filtering, and this change made the table the default for a roster its size — so it states which view it means rather than relying on a default that is no longer its own. Assertions unchanged.

tests/js/reports_roster_table.test.js is new: 26 tests over the real app.html and real app.css — who gets the table, status ordering, header sorting and direction flip, null-sorts-last, the toggle and its persistence, the filter and search reaching table rows and composing, the totals arithmetic, and both branches of the target column.

Two bugs the new tests found before review did:

  1. "No client matches" never appeared. The count of still-visible rows included the card-grid items — built either way, carrying no name hook — so an emptied search never looked empty.
  2. The totals row had no class, so nothing could address it without a descendant selector the harness does not support.

And two expectations I wrote wrong: a stale client withholds its spend, so it sorts last rather than by value, and it is out of the totals. The code was right and my test was not; both are corrected with the reasoning in place.

One thing worth noting for future UI work: the harness's click() only dispatches addEventListener handlers, so a control wired with .onclick is untestable by click. I used addEventListener throughout (with a one-time guard, since the toolbar is static markup and re-binding per draw would stack handlers). dashboard_reports_triage.js:115 still uses .onclick — pre-existing, not touched here.

Verification

  • node --test tests/js/*.test.jstests 422, pass 422, fail 0 (396 → 422)
  • pytest tests/ -q14 failed, 9898 passed, 8 skipped in 433.90s — all 14 enumerated by name, identical to the baseline set on main. Zero new.
  • Web-asset + i18n group → 405 passed
  • ruff check mureo/ tests/ → All checks passed
  • black --check mureo/ tests/ → 743 files unchanged
  • mypy mureo/ --ignore-missing-importsSuccess: no issues found in 359 source files
  • 0 literal px font-size values added (the 3 remaining are pre-existing relative em)

i18n.json gains 14 keys in both locales.

Agency multi-client capture review is yours.

Refs #691

Phase 3 of #691. A card grid answers "how is this client doing?" one client
at a time. Past a handful of clients the question becomes "which of these do
I open first?", and a grid answers that badly: the figures sit in a
different place on every card, so comparing two of them is a search rather
than a glance. A table puts one metric in one column, which is the whole of
what it is for.

WHO GETS IT. Two or more VISIBLE clients. A single-workspace OSS install
opens a client detail directly and never sees a roster; a roster down to one
visible client keeps the cards, because a one-row table is a worse card. The
cards remain as a toggle and the choice is remembered per browser — it is a
preference about reading, not a fact about the data, so it lives in
localStorage and never on the wire. A remembered "table" does not resurrect
one for a roster that has since shrunk.

What the table decides, and what it refuses to
----------------------------------------------
Status order is the default and it is an argument: needs-action, then watch,
then clear, biggest spender first inside each group. The ranking is
reports_triage.js own — a row the alert list calls urgent cannot sort as
healthy, because the table never forms a second opinion about health or
about any figure. Every number comes from the same aggregateClientKpis the
cards use.

A figure mureo will not state is "—" and sorts to the END in both
directions. Unknown is not a small number, and letting it float to the top
of an ascending sort would put the least informative rows where the most
urgent belong.

The totals row sums spend and conversions and takes CPA as the WEIGHTED
average — total spend over total conversions — not the mean of the
per-client CPAs, which would let a client spending 12,000 pull the roster
figure as hard as one spending 128,000. A client whose figures are withheld
is left out of the sums entirely rather than counted as zero. CTR has no
roster-level value worth stating, so it is "—".

The CPA-vs-target column
------------------------
It is in the approved design and it is NOT rendered, because nothing on the
reports wire carries a target CPA: no field on the summary, and STRATEGY.md
guardrails reach no web payload. Checked before building rather than
assumed.

A column of "—" the whole way down would advertise a comparison mureo cannot
make, so the column is omitted when no client has a target. The renderer
ships and is tested against a feed that has one, so the day a target reaches
the summary the column appears with no further work. This is the same shape
phase 4 has with #690: the view is ready and the datum is the dependency.

One filter, both views
----------------------
applyReportsHealthFilter now iterates every roster row rather than the card
grid children, so one implementation hides both views. It applies the
client-name search too, and that is deliberate rather than lazy: `hidden` on
these rows has exactly one owner. Two functions both writing it would each
undo the other depending on which ran last, which is the bug shape #665 is
about. The filter and the search compose; neither overrides.

Test changes
------------
reports_filter_interaction.test.js now switches to the cards explicitly.
That file is about the GRID filtering, and this change made the table the
default for a roster its size — so it says which view it means rather than
relying on a default that is no longer its own. Same assertions.

Two bugs the new tests found before review did:

  - "No client matches" never appeared. The count of still-visible rows
    included the card grid items, which are built either way and carry no
    name hook, so an emptied search never looked empty.
  - the totals row had no class, so nothing could address it without a
    descendant selector the harness does not support.

And two expectations I had written wrong: a stale client withholds its
spend, so it sorts LAST rather than by value, and it is out of the totals.
The code was right and the test was not.

Refs #691
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant