Skip to content

feat(portal): organizational analytics portal behind the insight.portal flag - #2241

Merged
dzarlax merged 9 commits into
constructorfabric:mainfrom
dzarlax:feat/portal-shell
Aug 5, 2026
Merged

feat(portal): organizational analytics portal behind the insight.portal flag#2241
dzarlax merged 9 commits into
constructorfabric:mainfrom
dzarlax:feat/portal-shell

Conversation

@dzarlax

@dzarlax dzarlax commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

An organizational analytics portal — a manager-first shell over the existing metric system — gated behind the insight.portal localStorage flag (default off; nothing changes for current users). New code lives under src/frontend/src/components/portal/ and src/frontend/src/lib/portal/, plus a few shared libs extracted with tests.

Turn it on: localStorage.setItem("insight.portal", "true") and reload (the flag is compared against the literal "true").

This replaces constructorfabric/insight-front#221, re-based onto the monorepo now that the frontend lives at src/frontend. Same tree, re-cut as five logical commits (charts, metric grammar, URL navigation, shell, zone views); the granular per-task history stays on dzarlax/insight-front-cf@feat/portal-shell for anyone who wants the step-by-step.

Why

The current dashboard is person-centric. Managers need the opposite entry point: the org first (their subtree), domains second, people last. The portal is that shell — and it doubles as a testbed for a uniform metric grammar, so every tab renders honestly from whatever connector set a tenant actually has (validated against two real datasets with very different coverage).

Structure

  • Zones (left rail): Overview / Directions / Person / People / AI & Cost / Manage.
  • Global topbar: Scope (any node of the viewer's subtree plus a direct-only switch — the permission boundary is the viewer's own tree), Slice (dimensions discovered from identity attributes; single-valued and near-unique ones are hidden automatically), and the period selector. The bar is sticky, so the three global controls stay reachable from anywhere in a long dashboard.
  • IC-aware: a viewer with no reports gets a Person-only shell.

Everything is in the URL

Navigation state used to live in memory. A reload reset the screen, Back went somewhere unrelated, and a link or a bookmark could not reproduce a view — the main objection in review on the previous PR.

Now: /portal?zone=&item=&dir=&lens=&scope=&direct=&slice=&period=&from=&to= plus /ic/$person/personal|team. Only true reader preferences (is the portal on, are planned sections shown) remain in localStorage.

  • Params are validated, not trusted: an unrecognised value is dropped so a mistyped link degrades to the computed default, and an inverted or over-long custom range falls back to the preset instead of throwing into the error boundary.
  • Effect-driven corrections (pinning the landing zone, syncing scope from the route) use replace, so Back never steps into a half-built address.
  • The path wins over a stale ?zone= — the URL cannot contradict itself.
  • Defaults stay computed rather than serialised, so a shared link pins what the sender actually chose and lets the rest resolve for the recipient.

Keyed on person ids

Person keys are canonical person ids everywhere: the /ic/$person/* route segment (guarded by isPersonId, which redirects a pre-cutover email URL instead of sending it to the API as an unactionable 400), ?scope=, the roster maps, and the metric entity ids. No email-to-id bridge — the frontend and metrics are on ids on dev, and a translation layer would outlive its reason to exist.

Two consequences worth naming: cohorts are keyed by id, so a person with no email still belongs to one and still appears in the employees directory; and the manager step in the person header uses parent_person_id.

The metric grammar (the core of the PR)

Directions and Overview render through ONE config-driven renderer (DomainLensView) over a typed section library (SectionSpec): headline, stat-tiles, trend, distribution, concentration, composition, participation, event-histogram, attention, direction-cards, coverage-radar. Registries (DIRECTION_LENSES, OVERVIEW_ITEMS) declare every tab; invariant tests pin nav/registry coverage both ways and the metric-key budget.

Rules the renderer enforces everywhere:

  • counters render per-active-person with a period-over-period delta coloured by the server-owned direction; ratios and medians are never summed;
  • distributions use integer 1/2/5-ladder bins; concentration is a top-decile share with explicit framing (bus-factor risk for git, load balance for collaboration);
  • honest data-gating: a family never observed for the scope renders a not-ingested state, never zeros (entityObserved reads the peer view's nulls, which the backend guarantees via join_use_nulls = 1, so zero-filled sums do not count as data);
  • self-suppression on degenerate data (cohorts below four, single-bin histograms, single-valued slices), always with a sentence saying why rather than going silent;
  • org zones never rank named individuals; names appear only in "needs attention" as click-through pointers into Person;
  • trend requests coarsen their bucket (day to week to month) from roster size, and when no bucket fits the row budget the section says so instead of firing a request the API rejects;
  • no status without a threshold. Deltas are coloured by direction, but nothing is labelled good or bad: the unified metric keys carry no threshold rows, and hardcoding good/warn in a component would turn a product decision into invisible frontend trivia. Status lights up the moment the catalog carries values ([pres] Declarative metric registry (single YAML) #1974).

Charts

Line charts were configured per call site, so the same reading looked different depending on the screen. ChartLine now owns the defaults: linear interpolation (a chart must not draw a value nobody measured), adaptive dots (dropped above 31 points, but an isolated reading between two nulls is always marked so a single point is never invisible), and gaps that read as gaps. ChartYAxis abbreviates ticks from 10k — the point where four digits still fit the gutter and five clip — and stays exact below it, because a tick sits on a gridline and "2.3k" for 2250 is a label that lies.

Three layout tiers

useShellLayout resolves phone (<768) / narrow (768-1023) / wide (>=1024) in one place: the rail is a drawer on a phone, collapsible on narrow, pinned on wide, and pane state resets when the tier changes instead of restoring a mode that no longer exists. On a phone the drawer shows a one-row zone switcher and folds settings behind a single row — the expanded list pushed the sections below the fold, which is the content the reader opened the drawer to reach.

Tests

785 unit tests over the new code, semantics-first rather than snapshot-first:

  • libs: attention flags (outliers, collapses, adverse deltas, cohort isolation, scale-free severity in cohort IQRs, dedup to the strongest flag per person and metric), slice discovery gates, peer quantiles with small-cohort suppression, per-active-person maths, trend bucket selection including the no-bucket-fits case;
  • hooks: URL-vs-path zone resolution, manager detection, person cohorts, org-scope resolution inside the viewer's own subtree;
  • components: the section renderer (per-capita and deltas, active-only denominators, not-ingested gate, distribution suppression, concentration framing, composition shares and error card, participation, by-unit under a slice, attention rows, radar suppression), team state (totals vs medians, empty columns dropped), AI & Cost (Claude-only cost caveat, "not tracked" never $0), metric groups, context pane, shell routing across the three tiers, attention list, org-scope gate (an error never masquerades as an empty team), employees directory, Manage.

src/test/portal-router is a reactive router fake so navigation is asserted as real hrefs and recorded navigations; src/test/identity mints valid person UUIDs from short labels, so fixtures stay readable without drifting from the real key shape.

Verified in the ported tree: pnpm typecheck clean, eslint --max-warnings 0 clean, 785 unit tests pass. The two Storybook browser suites are unchanged by this PR.

Verified live

Walked every zone and tab at org scope (150+ people) and at narrowed scopes on two real datasets with different connector sets: full coverage on one; the other missing task and git freshness — every gap rendered as an explicit not-ingested state instead of fabricated zeros, which is exactly the behaviour this PR is after.

Out of scope / follow-ups

All five commits are signed off (DCO).

Summary by CodeRabbit

  • New Features
    • Added a responsive Portal experience with navigation, organization scoping, team and employee views, personal dashboards, metric groups, trends, attention insights, and AI cost reporting.
    • Added configurable overview, direction, and metric-management views with loading, error, empty, and Coming Soon states.
    • Added portal settings for enabling the experience and showing planned sections.
    • Added responsive charts, compact axis labels, period and slice selectors, and persistent period preferences.
  • Bug Fixes
    • Prevented metric requests when entity lists or date ranges are incomplete.
    • Improved chart handling for sparse and isolated data points.

Alexey Panfilov added 5 commits August 5, 2026 14:54
Line charts were configured per call site, so the same reading rendered
differently depending on which screen you opened: monotone smoothing that
invents values between points, dots that either vanish on dense series or
crowd them, and raw axis ticks that clip at five digits.

- ChartLine wraps Recharts' Line with linear interpolation (a chart must
  not draw a value nobody measured), AdaptiveDot (dots above
  DOT_DENSITY_LIMIT = 31 points are dropped, but an isolated reading
  between two nulls is always marked so a single point is never invisible),
  and connectNulls off by default.
- ChartYAxis defaults tickFormatter to formatAxisTick, which abbreviates
  from 10k — the point where four digits still fit an axis gutter and five
  do not. Below that it stays exact, because a tick sits on a gridline and
  "2.3k" for 2250 is a label that lies.
- metric-timeseries-chart drops its local IsolatedPoint in favour of the
  shared behaviour.

Signed-off-by: Alexey Panfilov <alexey.panfilov@constructor.tech>
The rules every portal section obeys, in libraries rather than repeated per
screen — an org number computed differently on two screens is a number
nobody can act on.

- metric-stats: per-active-person values (an org total divided by headcount
  hides who is idle; the denominator is people with a non-zero reading),
  period-over-period deltas, medians for ratios, integer 1/2/5 histogram
  bins that self-suppress when degenerate, top-decile concentration, and
  group coverage.
- trend-data: pickTrendBucket coarsens day to week to month so a long
  period still renders; when no bucket fits the row budget it returns null
  and the section says so instead of firing a request the API rejects.
- attention-flags: outliers, declines and collapses ranked on one
  scale-free severity (distance in cohort IQRs, falling back to the median
  when the IQR is degenerate), deduplicated to the strongest flag per
  person and metric.
- slices: attribute-agnostic cohorts discovered from the roster, gated by
  presence and cardinality so an id-like attribute never becomes a slice.
- within-team-peer, event-histogram, lens-configs, overview-configs: the
  per-zone section registries and the peer/event shapes they read.
- collection / metric-results: honest emptiness — the queries stay disabled
  until a roster resolves and the client refuses an empty entity list,
  rather than sending a request that returns a 400 the reader cannot act
  on. `entityObserved` distinguishes "never ingested" from "measured zero"
  via the peer view's nulls.

Signed-off-by: Alexey Panfilov <alexey.panfilov@constructor.tech>
Navigation state lived in memory, so a reload reset the screen, Back went
somewhere unrelated, and a link or bookmark could not reproduce a view.
Everything that answers "what am I looking at" now rides in the URL:
/portal?zone=&item=&dir=&lens=&scope=&direct=&slice=&period=&from=&to=,
plus /ic/$person/personal|team for the person-scoped screens. Only true
reader preferences (is the portal on, are planned sections shown) stay in
localStorage.

- portal-search validates rather than trusts: an unrecognised param is
  dropped so a mistyped link degrades to the computed default, and an
  inverted or over-long custom range falls back to the period preset
  instead of throwing into the error boundary.
- portal-nav / use-zone-nav read the URL and navigate once per click, with
  `replace` for effect-driven corrections (pinning the landing zone,
  syncing scope from the route) so Back never steps into a half-built
  address. The path wins over a stale `?zone=` — the URL cannot contradict
  itself.
- Person keys are canonical person ids throughout: the route segment (which
  `isPersonId` guards, redirecting a pre-cutover email URL rather than
  sending it to the API), `?scope=`, the roster maps and the metric entity
  ids are all the same key.
- use-org-scope resolves the scope inside the VIEWER's own subtree, so a
  hand-edited `?scope=` cannot reach outside what the viewer may see.
- src/test/portal-router is a reactive router fake, and src/test/identity
  mints valid person UUIDs from short labels so fixtures stay readable
  without drifting from the real key shape.

Signed-off-by: Alexey Panfilov <alexey.panfilov@constructor.tech>
…t tiers

The portal shell: a zone rail on the left, the global controls (scope,
slice, period) in a sticky top bar, and the zone's own content in between.

- useShellLayout resolves phone (<768) / narrow (768-1023) / wide (>=1024)
  from one place, so the rail is a drawer on a phone, collapsible on
  narrow, and pinned on wide, and pane state resets when the tier changes
  rather than restoring a mode that no longer exists.
- The top bar is sticky and horizontally scrollable on a phone, so scope,
  slice and period stay reachable from anywhere in a long dashboard;
  `justify-end` is desktop-only because inside a scroller it pushes the
  first controls past the unreachable start edge.
- On a phone the drawer shows a one-row zone switcher and folds settings
  behind a single row — the expanded list pushed the sections below the
  fold, which is exactly the content the reader opened the drawer to reach.
- OrgScopeGate is the single honest gate in front of every org zone: the
  viewer's roster is still loading, the scope is empty, or identity failed,
  each said plainly with a retry rather than an empty chart.
- Scope and slice selects read and write the URL; the org tree drills into a
  lead's team and sets the scope in the same click.

Signed-off-by: Alexey Panfilov <alexey.panfilov@constructor.tech>
Every zone renders through one section renderer instead of a bespoke
screen, so a metric reads the same way wherever it appears and a new zone
is a registry entry rather than a new component tree.

- DomainLensView renders the section kinds the grammar defines: headline
  (per-active-person value, team total and PoP delta), stat tiles (medians,
  never sums), distribution, participation (N of M active), concentration
  with the framing the domain deserves (bus-factor risk for git, load
  balance for collaboration), composition from real server dimensions only,
  by-unit cohorts when a slice is active, attention rows, direction cards
  and the coverage radar.
- Overview, Directions, People, Person, AI cost and Manage are configs over
  that renderer; overview-view is a router, not a screen.
- Honest emptiness throughout: a family nothing ever ingested says so, a
  section too small to compare explains why instead of going silent, a
  suppressed radar states its minimum cohort, and no surface paints a zero
  where there is no measurement.
- The attention list links each row to that person by id, and the person
  header steps up to the manager via parent_person_id.

Signed-off-by: Alexey Panfilov <alexey.panfilov@constructor.tech>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@dzarlax, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 89636081-3a8f-4658-afc1-0367317d9702

📥 Commits

Reviewing files that changed from the base of the PR and between 2902126 and 115a7e0.

📒 Files selected for processing (18)
  • src/frontend/src/components/portal/metric-groups-view.tsx
  • src/frontend/src/components/portal/org-scope-gate.tsx
  • src/frontend/src/components/portal/section-trend.tsx
  • src/frontend/src/hooks/use-portal-period.test.tsx
  • src/frontend/src/lib/insight/attention-flags.ts
  • src/frontend/src/lib/insight/slices.ts
  • src/frontend/src/lib/portal/event-histogram.ts
  • src/frontend/src/lib/portal/lens-configs.ts
  • src/frontend/src/lib/portal/nav-model.ts
  • src/frontend/src/lib/portal/portal-nav.ts
  • src/frontend/src/lib/portal/portal-search.ts
  • src/frontend/src/lib/portal/portal-store.ts
  • src/frontend/src/lib/portal/route-scope-sync.test.ts
  • src/frontend/src/lib/portal/route-scope-sync.ts
  • src/frontend/src/lib/portal/use-cohort-label.ts
  • src/frontend/src/lib/portal/use-org-scope.ts
  • src/frontend/src/lib/portal/use-shell-layout.ts
  • src/frontend/src/test/portal-router.tsx
📝 Walkthrough

Walkthrough

The change adds a portal application with URL-backed navigation, organization-scoped metric views, responsive layout components, configurable analytics sections, metric utilities, request guards, chart defaults, and test coverage.

Changes

Portal analytics platform

Layer / File(s) Summary
Analytics contracts and calculation utilities
src/frontend/src/lib/insight/*, src/frontend/src/lib/portal/*, src/frontend/src/lib/metrics/*
Added slice, cohort, peer-statistic, attention-flag, metric-statistic, trend, histogram, navigation, overview, and lens configuration utilities.
Portal state and URL navigation
src/frontend/src/lib/portal/*, src/frontend/src/hooks/use-portal-period.ts
Added persisted portal preferences, validated URL state, period handling, organization scope resolution, active-zone detection, and navigation actions.
Portal routing and responsive shell
src/frontend/src/routes/*, src/frontend/src/components/portal/portal-layout.tsx, src/frontend/src/components/portal/context-pane.tsx, src/frontend/src/components/portal/lens-rail.tsx
Added the /portal route, responsive shell, zone rail, context pane, scope selector, slice selector, and portal top bar.
Scoped portal views
src/frontend/src/components/portal/*-view.tsx
Added overview, direction, AI cost, team, people, employee, person, group, manage, and zone-content views with loading, error, empty, retry, and planned states.
Shared UI and request safeguards
src/frontend/src/api/*, src/frontend/src/components/ui/chart.tsx, src/frontend/src/components/sidebar-settings.tsx
Added empty-entity request guards, exported schema status, shared sidebar footer extraction, portal settings, compact axis labels, adaptive chart dots, linear chart defaults, and responsive period labels.
Validation and test coverage
src/frontend/src/**/*.test.*, src/frontend/src/test/*
Added coverage for portal routing, views, hooks, analytics utilities, metric queries, chart behavior, responsive layouts, and identity/router fixtures.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • constructorfabric/insight#1656: The main PR directly builds on the retrieved PR’s unified metrics API and schema by adding frontend consumers, validation for empty metric entity lists, and public schema-status access.
  • constructorfabric/insight#2003: The PRs both modify metric-results handling: the main PR adds client-side empty-entity validation while the retrieved PR extends the backend query_metric_results response with drilldown metadata.

Suggested reviewers: ktursunov

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: addition of an organizational analytics portal behind a feature flag. It accurately represents the primary objective of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/frontend/src/components/ui/chart.tsx (1)

122-216: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Do not hand-edit the vendored chart component.

Move the YAxis, AdaptiveDot, ChartLine, and ChartArea behavior into an application-owned wrapper outside src/components/ui/. Alternatively, regenerate this component through the approved shadcn/ui workflow.

As per coding guidelines, treat src/components/ui/ as vendored shadcn/ui code and regenerate components instead of hand-editing them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/ui/chart.tsx` around lines 122 - 216, Move the
custom YAxis, AdaptiveDot, ChartLine, and ChartArea behavior out of the vendored
src/components/ui chart component into an application-owned wrapper, preserving
their current defaults and behavior. Alternatively, remove the hand edits and
regenerate the chart component through the approved shadcn/ui workflow.

Source: Coding guidelines

🟡 Minor comments (12)
src/frontend/src/components/widgets/period-selector-bar.tsx-126-130 (1)

126-130: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add an accessible name for the compact date-range trigger.

Below sm, the active range text is hidden. The remaining UTC text does not identify the date-range control or its current value. Add an aria-label that includes activeRangeLabel.

Proposed fix
-              <ToggleGroupItem value="custom" className="gap-1.5">
+              <ToggleGroupItem
+                value="custom"
+                className="gap-1.5"
+                aria-label={`Custom date range: ${activeRangeLabel}`}
+              >
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/widgets/period-selector-bar.tsx` around lines 126
- 130, Update the ToggleGroupItem for the custom date range to include an
aria-label containing activeRangeLabel, so the compact trigger remains
identifiable when its visible range text is hidden below the sm breakpoint.
src/frontend/src/lib/format.ts-99-103 (1)

99-103: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle the rounded-thousand rollover.

formatAxisTick(999_999) returns 1000k because the million check runs before thousand rounding. Return the million form when rounding reaches 1,000 thousand.

Proposed fix
 export function formatAxisTick(v: number): string {
   const abs = Math.abs(v);
   if (abs >= 1_000_000) return `${trimTrailingZero((v / 1_000_000).toFixed(1))}M`;
-  if (abs >= 10_000) return `${Math.round(v / 1000)}k`;
+  if (abs >= 10_000) {
+    const thousands = Math.round(v / 1000);
+    if (Math.abs(thousands) >= 1_000) {
+      return `${trimTrailingZero((v / 1_000_000).toFixed(1))}M`;
+    }
+    return `${thousands}k`;
+  }
   return trimTrailingZero((Math.round(v * 10) / 10).toFixed(1));
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/format.ts` around lines 99 - 103, Update formatAxisTick
so the thousand-format branch detects when rounding v to thousands reaches 1,000
in magnitude and formats that result as the million representation instead of
returning 1000k. Preserve existing formatting for values below the rollover and
for values already handled by the million branch.
src/frontend/src/components/portal/shell-layout.test.tsx-31-43 (1)

31-43: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use one mock factory for useViewerIsManager.

The later vi.mock("@/lib/portal/use-viewer-is-manager", ...) replaces the state-controlled factory, so mocks.isManager changes cannot affect this mock. Remove the duplicate mock block and keep the shared factory above.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/shell-layout.test.tsx` around lines 31 -
43, Remove the duplicate vi.mock block for useViewerIsManager in the shell
layout test, preserving the earlier shared factory that reads mocks.isManager.
Keep the other component mocks unchanged so tests can continue controlling
manager state through mocks.isManager.
src/frontend/src/lib/insight/attention-flags.ts-211-212 (1)

211-212: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix the plural form when the scope holds one person.

With teamSize === 1 the text reads "All 1 people are within their usual range this period." A single-person scope is reachable, because the caller only suppresses the section when memberIds.length is 0.

🐛 Proposed fix
   if (flags.length === 0)
-    return `All ${teamSize} people are within their usual range this period.`;
+    return teamSize === 1
+      ? "This person is within their usual range this period."
+      : `All ${teamSize} people are within their usual range this period.`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/insight/attention-flags.ts` around lines 211 - 212,
Update the zero-flags message in the attention-flags logic to use singular
wording when teamSize is 1 and plural wording otherwise, preserving the existing
message and behavior for multi-person scopes.
src/frontend/src/lib/portal/metric-stats.ts-91-93 (1)

91-93: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clamp the bin index at zero.

distribution only rejects the case where the maximum is not positive. A set that mixes negative and positive values still passes. For a negative v, Math.floor(v / step) is negative and Math.min(nBins - 1, …) keeps it negative. The write then lands on an out-of-range array property, so the value is dropped from every bin and the rendered counts no longer sum to the population.

🐛 Proposed fix
   for (const v of values) {
-    counts[Math.min(nBins - 1, Math.floor(v / step))] += 1;
+    const bin = Math.min(nBins - 1, Math.max(0, Math.floor(v / step)));
+    counts[bin] += 1;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/portal/metric-stats.ts` around lines 91 - 93, Clamp the
computed bin index to the valid range in the values loop of distribution,
ensuring negative values map to bin 0 while values above the upper bound still
map to nBins - 1. Preserve the existing count increment so every value
contributes to exactly one bin.
src/frontend/src/lib/portal/use-viewer-is-manager.ts-21-28 (1)

21-28: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Keep manager status unresolved when identity data is absent.

If q.data is null while q.isPending is false, this hook returns a confirmed non-manager. PortalLayout then clears a manager’s selected organization zone and hides organization navigation. Return an unresolved or error state until the identity query can confirm the viewer node. Add a regression test for a null, non-pending result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/portal/use-viewer-is-manager.ts` around lines 21 - 28,
Update the isManager calculation in useViewerIsManager so a null q.data result
remains unresolved or reports an error when q.isPending is false, rather than
returning false; preserve confirmed false only after identity data is available
and the viewer node is evaluated. Ensure PortalLayout does not clear
manager-specific state for missing identity data, and add a regression test
covering null, non-pending query data.
src/frontend/src/lib/portal/use-cohort-label.ts-30-31 (1)

30-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

toLowerCase() mangles acronyms and proper nouns in the slice label.

dims supplies the label. Lower-casing it turns "EMEA" into "emea" and "AI Platform" into "ai platform". The consumer renders the result mid-sentence as "vs median" (src/frontend/src/components/portal/single-group-view.tsx line 82 passes cohortLabel into CollectionDrilldown), so the damage is visible in exactly the string this hook exists to get right.

Return the supplied label unchanged. If a specific surface needs lower case, apply the transform with CSS at that surface.

♻️ Proposed fix
   if (!slice) return "team";
-  return (dims.find((d) => d.key === slice)?.label ?? "cohort").toLowerCase();
+  return dims.find((d) => d.key === slice)?.label ?? "cohort";

As per coding guidelines: "Use metric names, descriptions, and units supplied by API responses; do not invent user-facing metric semantics in the frontend."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/portal/use-cohort-label.ts` around lines 30 - 31, Update
the slice-label return in useCohortLabel to preserve the label supplied by dims
unchanged; remove the toLowerCase transformation while retaining the existing
"cohort" fallback and "team" behavior when no slice is selected.

Source: Coding guidelines

src/frontend/src/components/portal/single-group-view.tsx-43-54 (1)

43-54: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle a failed cohort fetch explicitly.

The view gates on data.isPending and data.isError. It never reads cohortData.isError or cohortData.isPending. If the cohort query fails, cohortData.byKey is empty, injectCohortPeer injects no peer stats, and CollectionDrilldown still receives cohortLabel. The drilldown then presents a comparison against a cohort whose data never arrived.

The comment at lines 64-65 states the policy for this file: a failed fetch must surface as a retryable error rather than a view rendered over an empty dataset. Apply the same decision to the cohort query, or suppress cohortLabel when the cohort data is unavailable so the view makes no comparison claim it cannot support.

🐛 Proposed change — suppress the comparison label instead of failing the view
   const cohortLabel = useCohortLabel();
+  const cohortReady = cohortIds.length > 0 && !cohortData.isError && !cohortData.isPending;
   return (
     <div className="flex flex-col gap-4 p-4 md:p-6">
       <h1 className="text-xl font-semibold tracking-tight">{def.title}</h1>
       <CollectionDrilldown
         def={def}
         data={injectedData}
         entityId={entityId}
         range={dateRange}
-        cohortLabel={cohortLabel}
+        cohortLabel={cohortReady ? cohortLabel : undefined}
       />

Confirm that CollectionDrilldown accepts an absent cohortLabel and hides the peer story.

Also applies to: 63-72

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/single-group-view.tsx` around lines 43 -
54, Update the cohort handling in the component’s data flow to account for
cohortData.isError and cohortData.isPending. When cohort data is unavailable,
suppress the cohortLabel passed to CollectionDrilldown so the view does not
claim a comparison; otherwise preserve the existing label and injected peer
statistics. Confirm the CollectionDrilldown prop contract supports an absent
cohortLabel.
src/frontend/src/components/portal/people-view.tsx-39-44 (1)

39-44: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The person comparison is case-sensitive, unlike personIdEq.

person arrives from the /ic/$person route segment. src/frontend/src/components/org-tree.tsx lines 19-21 compare person ids with personIdEq, which lowercases both sides. This effect uses !== and then stores the raw segment with replaceScope({ root: person }).

If a pasted URL differs only in case from the id in the identity tree, two results follow. The effect syncs again for what is the same person. The stored scope root no longer matches pivotPersonId under the === check in src/frontend/src/components/portal/scope-select.tsx line 58, so the active-scope check mark does not render.

Normalize the route segment before you compare it and before you store it, or confirm that person ids are case-stable end to end.

#!/bin/bash
# Description: Check whether person ids are case-normalized across routes, scope, and identity.
set -euo pipefail

# Every case-insensitive person-id comparison already in the frontend.
rg -n -C3 --type=tsx --type=ts 'toLowerCase\(\)' src/frontend/src/lib/portal src/frontend/src/components src/frontend/src/lib/metrics

# The canonical normalizer and whether it lowercases.
fd -t f 'entity.ts' -x ast-grep outline {} --items all
rg -n -C6 'export function normalizePersonId' src/frontend/src

# How the scope root is resolved against the identity tree.
rg -n -C6 'resolveScopeRoster' src/frontend/src/lib/portal/use-org-scope.ts
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/people-view.tsx` around lines 39 - 44,
Normalize the route segment in the effect around lastRouteSync before comparing
or storing it, using the existing person-id normalization or case-insensitive
equality convention such as personIdEq. Pass the normalized value to
replaceScope({ root: ... }) so scope.root remains consistent with pivotPersonId
and equivalent casing does not trigger repeated synchronization.
src/frontend/src/components/org-tree.tsx-110-115 (1)

110-115: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

decodeURIComponent can throw during render.

decodeURIComponent raises a URIError on a malformed percent sequence, for example the pathname /ic/%E0%A4%A. The call runs inside useMemo during render, so the error propagates to the nearest error boundary and unmounts the tree. A pasted URL reaches this path, and this component parses the raw pathname itself rather than reading a validated route parameter.

Return null for an undecodable segment. The tree then highlights no node instead of failing.

🐛 Proposed change
   const activePersonId = useMemo(() => {
     const m = /^\/ic\/([^/]+)/.exec(pathname);
-    if (m) return decodeURIComponent(m[1]!);
+    if (m) {
+      try {
+        return decodeURIComponent(m[1]!);
+      } catch {
+        // A malformed percent sequence in a pasted URL matches no person.
+        return null;
+      }
+    }
     if (pathname === "/" && viewerPersonId) return viewerPersonId;
     return null;
   }, [pathname, viewerPersonId]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/org-tree.tsx` around lines 110 - 115, Update the
activePersonId useMemo to safely handle URIError from decodeURIComponent for the
matched /ic/ path segment, returning null when decoding fails while preserving
the existing decoded-ID and viewerPersonId behavior for valid paths.
src/frontend/src/components/portal/team-state-view.tsx-259-259 (1)

259-259: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Apply the same teamName fallback to the table caption.

Line 212 falls back to "Team" when teamName is empty. Line 259 interpolates teamName with no fallback, so the caption becomes " — members × metrics" with a leading separator. The caption is the accessible name for the members table, so screen-reader output starts with punctuation.

🐛 Proposed change
-              caption={`${teamName} — members × metrics`}
+              caption={`${teamName || "Team"} — members × metrics`}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/team-state-view.tsx` at line 259, Update
the table caption in team-state-view so it uses the same teamName fallback
already applied elsewhere in this component, instead of interpolating teamName
directly. Adjust the caption expression near the members table to reuse the
existing fallback logic (the one that returns “Team” when teamName is empty) so
the accessible name does not শুরু with a leading separator when teamName is
missing.
src/frontend/src/components/portal/ai-cost-view.tsx-338-342 (1)

338-342: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the API-supplied format and unit for every currency and count value.

Line 329 and Line 335 read costR?.format/costR?.unit and linesR?.format/linesR?.unit. Line 340, Line 379, and Line 386 hardcode "currency"/"USD" and "integer"/null instead. costR and linesR are already in scope at Line 296 and Line 297. If the API reports a different unit, the tiles disagree with each other.

♻️ Proposed change for the average tile
         <Tile
           label="Avg cost / active user"
-          value={formatMetricValue(avgCost, "currency", "USD")}
+          value={formatMetricValue(avgCost, costR?.format ?? "currency", costR?.unit ?? "USD")}
           sub="Claude Code"
         />

Apply the same change to the per-tool card values at Line 379 and Line 386.

As per coding guidelines: "Use metric names, descriptions, and units supplied by API responses; do not invent user-facing metric semantics in the frontend."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/ai-cost-view.tsx` around lines 338 - 342,
Update the average-cost Tile and the per-tool cost/count values to use the
API-supplied format and unit from costR and linesR, matching the existing
handling in the surrounding metric tiles. Remove the hardcoded currency/USD and
integer/null arguments while preserving each tile’s current metric source and
labels.

Source: Coding guidelines

🧹 Nitpick comments (21)
src/frontend/src/components/portal/employees-view.test.tsx (1)

41-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Uncleared vi.hoisted spies make the retry assertions order-dependent. Both files create a refetch spy once inside vi.hoisted, reset only the plain data fields in beforeEach, and then assert toHaveBeenCalledOnce on that spy. The call count accumulates across every test in the file, so the assertion holds only while exactly one test triggers retry. Clear the spy in beforeEach in each file.

  • src/frontend/src/components/portal/employees-view.test.tsx#L41-L52: add mocks.ic.refetch.mockClear() to the beforeEach block.
  • src/frontend/src/components/portal/manage-view.test.tsx#L49-L51: add mocks.q.refetch.mockClear() to the beforeEach block.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/employees-view.test.tsx` around lines 41 -
52, Clear the hoisted retry spies in each test setup before assertions run: add
mocks.ic.refetch.mockClear() to the beforeEach block in
src/frontend/src/components/portal/employees-view.test.tsx (lines 41-52) and
mocks.q.refetch.mockClear() to the beforeEach block in
src/frontend/src/components/portal/manage-view.test.tsx (lines 49-51),
preserving the existing test data initialization.
src/frontend/src/components/portal/attention-list.test.tsx (2)

21-40: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse the shared pid fixture helper.

src/frontend/src/test/identity.ts exports pid(label), which mints a deterministic person UUID. Other portal tests in this layer use it. This file defines a second local minting helper ID(n) for the same purpose. Use pid here so all portal tests produce identity keys through one helper.

Note that ID(n) also breaks its own UUID shape for n >= 10, because the first group grows past 8 characters.

♻️ Proposed refactor
-import type { AttentionFlag } from "`@/lib/insight/attention-flags`";
+import type { AttentionFlag } from "`@/lib/insight/attention-flags`";
+import { pid } from "`@/test/identity`";
-// Person ids, not emails: rows link by the identity-cutover key.
-const ID = (n: number) => `0000000${n}-1111-4111-8111-111111111111`;
+// Person ids, not emails: rows link by the identity-cutover key.
+const ID = (n: number) => pid(`p${n}`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/attention-list.test.tsx` around lines 21 -
40, Replace the local identity-minting helper in attention-list.test.tsx with
the shared pid(label) fixture from src/frontend/src/test/identity.ts, and update
the FLAGS/flag setup to use pid for personId values instead of ID(n). Keep the
existing AttentionFlag shape and test data behavior unchanged, but remove the
custom ID helper so all portal tests mint deterministic person UUIDs through the
same shared symbol.

64-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reset the portal router state between tests.

This test writes zone: "overview" into the shared portalRouter store. The file has no beforeEach reset, unlike ai-cost-view.test.tsx and team-state-view.test.tsx. The state then persists into later tests in this file. Add a beforeEach that resets the zone so test order does not affect results.

♻️ Proposed change
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
 describe("AttentionList", () => {
+  beforeEach(() => {
+    act(() => portalRouter.set({ zone: undefined }));
+  });
+
   it("renders the summary, people label and flag rows with reasons", () => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/attention-list.test.tsx` around lines 64 -
70, Add a beforeEach setup in the attention-list test suite that resets the
shared portalRouter zone before every test, ensuring the zone written by the
test around renderZone and AttentionList cannot persist across tests or affect
their results.
src/frontend/src/components/portal/portal-shell.test.tsx (2)

215-224: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The second render mounts a new instance; it does not remount the first.

The comment says "a remount for the SAME person must NOT revert it". The test calls render twice without unmounting, so two PeopleView instances are mounted at the same time. The assertion still proves the one-shot sync guard is shared rather than per-instance. It does not prove the unmount-then-mount case the comment describes. Unmount the first tree before the second render, or update the comment to match what the test asserts.

♻️ Proposed change
   it("syncs the route person into the org scope ONCE, then defers to the user", () => {
     const scope = renderHook(() => usePortalScope());
-    render(<PeopleView person="p2@x" item={null} />);
+    const first = render(<PeopleView person="p2@x" item={null} />);
     expect(scope.result.current.root).toBe("p2@x");
     // The user re-picks a scope from the topbar…
     act(() => portalRouter.set({ scope: "other@x" }));
     // …and a remount for the SAME person must NOT revert it.
+    first.unmount();
     render(<PeopleView person="p2@x" item={null} />);
     expect(scope.result.current.root).toBe("other@x");
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/portal-shell.test.tsx` around lines 215 -
224, Update the test around usePortalScope and PeopleView so it actually covers
the stated remount behavior: retain the first render’s result, unmount that tree
after the user changes the scope, then render the same person again and assert
the user-selected scope remains. Alternatively, revise the test description to
describe concurrent instances if that is the intended coverage.

19-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use canonical person IDs in the person fixtures.

These fixtures pass emails as person keys: activePerson: "boss@x", person="a@x", and person="p1@x"/"p2@x". src/frontend/src/test/identity.ts documents that person keys are canonical UUIDs since the identity cutover, and that the route guard redirects any other shape. Every other portal test in this layer mints keys with pid(...).

The assertions still pass because the stubbed leaf views only echo the prop. The fixtures therefore do not exercise the real key shape, and they would not catch a person-ID validation added to PersonView or PeopleView. Replace the email literals with pid(...) values.

Also applies to: 190-224

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/portal-shell.test.tsx` around lines 19 -
24, Update the person fixtures in the portal shell tests to use canonical UUID
keys generated through the existing pid(...) helper. Replace
mocks.zone.activePerson and the person values in the affected test cases,
including the p1/p2 fixtures, while keeping email fields as email addresses.
Ensure these fixtures exercise the same person-ID shape expected by PersonView,
PeopleView, and the route guard.
src/frontend/src/components/portal/domain-lens-view.test.tsx (1)

60-71: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reset the useMetricCollection call counter between tests.

The mock keeps a module-scoped call counter that never resets. The slot each caller receives depends on the total number of useMetricCollection calls made by all previous tests. Alignment holds only while every render produces exactly three calls. If a render count changes, tests such as the composition cases at Lines 300-348 silently receive the wrong collection and assert against the wrong data.

Reset the counter in beforeEach so each test starts at slot 0.

♻️ Proposed change
 const mocks = vi.hoisted(() => ({
   personId: null as string | null,
   tree: undefined as IdentityPerson | undefined,
+  call: 0,
   grid: {
 vi.mock("`@/queries/metric-results`", () => ({
-  useMetricCollection: (() => {
-    let call = 0;
-    return () => {
-      const r = mocks.collections[call % 3] ?? emptyCollection();
-      call += 1;
-      return r;
-    };
-  })(),
+  useMetricCollection: () => {
+    const r = mocks.collections[mocks.call % 3] ?? emptyCollection();
+    mocks.call += 1;
+    return r;
+  },
 }));
 beforeEach(() => {
   seedHappyOrg();
+  mocks.call = 0;
   mocks.grid.isPending = false;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/domain-lens-view.test.tsx` around lines 60
- 71, Reset the module-scoped call counter used by the mocked
useMetricCollection in the test file before each test, ensuring every test
starts at slot 0 while preserving the existing three-call ordering and
collection selection.
src/frontend/src/lib/insight/within-team-peer.ts (1)

59-93: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Repeated forEntity scans inside per-member loops. forEntity in src/frontend/src/lib/metrics/collection.ts runs one find and three filter passes over the complete period, timeseries, breakdown, and histogram arrays. Every site below calls it once per member inside a loop, and those arrays also grow with the roster, so the cost is quadratic in headcount and is repeated for each metric in the grid. Build the per-entity index once per result and reuse it.

  • src/frontend/src/lib/insight/within-team-peer.ts#L59-L93: build a Map<string, number | null> of period values once, then use it at Line 64 and Line 83 instead of calling forEntity twice per member.
  • src/frontend/src/lib/insight/attention-flags.ts#L78-L83: index the period values of r once per metric key, then read each member's value from that index when building points; apply the same treatment to the prev lookup at Line 172.
  • src/frontend/src/lib/portal/trend-data.ts#L59-L71: group r.timeseries.series by entity_id once per metric key, then iterate the member ids against that grouping instead of calling forEntity for every key-member pair.

A shared helper on NormalizedMetricResult that returns an entity-indexed view would remove the duplication across all three call sites.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/insight/within-team-peer.ts` around lines 59 - 93,
Replace repeated per-member forEntity scans with a reusable entity-indexed view,
preferably a shared helper on NormalizedMetricResult. In
src/frontend/src/lib/insight/within-team-peer.ts lines 59-93, build one
period-value Map and reuse it for cohort bucketing and returned target values;
in src/frontend/src/lib/insight/attention-flags.ts lines 78-83, index r period
values once per metric key and reuse the index for points, applying the same
change to the prev lookup at line 172; in
src/frontend/src/lib/portal/trend-data.ts lines 59-71, group r.timeseries.series
by entity_id once per metric key and read member values from that grouping.
src/frontend/src/lib/portal/metric-stats.ts (1)

125-132: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reuse formatAxisTick in the distribution bins.

fmtCompact only formats thousands, so million-level summary values become long labels like 1000k. formatAxisTick already handles M labels, rounds nearby axis ticks, and is covered by the existing axis formatter tests. Use it for the non-percent distribution formatter instead of maintaining a separate compact formatter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/portal/metric-stats.ts` around lines 125 - 132, Replace
the separate fmtCompact logic used for non-percent distribution bin labels with
the existing formatAxisTick formatter. Update the distribution formatting path
to reuse formatAxisTick so million-level values receive M labels and its
established rounding behavior; remove fmtCompact if it is no longer referenced.
src/frontend/src/lib/portal/portal-store.ts (1)

3-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update the module doc: zone no longer lives here.

The comment describes zone as in-memory navigation state of this store. PortalState holds only enabled and showPlanned, and the zone now rides in the URL (portal-search.ts, portal-nav.ts). Remove the zone paragraph so the doc matches the module.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/portal/portal-store.ts` around lines 3 - 11, Update the
module documentation comment in the portal store to remove the paragraph
describing zone as in-memory navigation state, since PortalState only contains
enabled and showPlanned; retain the remaining enabled and feature-flag
documentation.
src/frontend/src/lib/portal/use-org-scope.ts (1)

64-76: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

walk recomputes subtree sizes quadratically.

walk visits every node once. For each manager node it calls flattenSubordinates(node), which walks that node's entire subtree again. The total cost is O(n · depth), and it degrades to O(n²) for a deep reporting chain.

One post-order pass yields every subtree size. This matters more because the memo above currently never hits — see the usePortalScope identity comment on src/frontend/src/lib/portal/portal-nav.ts lines 43-46.

♻️ Proposed fix
   const managerNodes: ManagerNode[] = [];
-  const walk = (node: IdentityPerson, depth: number): void => {
-    if (node.subordinates.length > 0) {
-      managerNodes.push({
-        person_id: node.person_id,
-        name: node.display_name || node.email,
-        depth,
-        teamSize: flattenSubordinates(node).length,
-      });
-    }
-    for (const sub of node.subordinates) walk(sub, depth + 1);
-  };
-  walk(viewerNode, 0);
+  // Post-order: each node returns its own subtree size, so `teamSize` costs
+  // one visit per node instead of a fresh flatten per manager.
+  const walk = (node: IdentityPerson, depth: number): number => {
+    let size = 0;
+    for (const sub of node.subordinates) size += walk(sub, depth + 1) + 1;
+    if (node.subordinates.length > 0) {
+      managerNodes.push({
+        person_id: node.person_id,
+        name: node.display_name || node.email,
+        depth,
+        teamSize: size,
+      });
+    }
+    return size;
+  };
+  walk(viewerNode, 0);
+  // Restore pre-order ordering for the picker.
+  managerNodes.sort((a, b) => a.depth - b.depth);

Verify the ordering ScopeSelect expects before applying the sort. The original walk pushed in pre-order, and the picker indents by depth. If it relies on parent-before-child sibling ordering, collect the entries in pre-order and fill teamSize in a second pass instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/portal/use-org-scope.ts` around lines 64 - 76, Update
the manager-tree traversal around walk to compute each node’s subtree size in a
single post-order pass instead of calling flattenSubordinates for every manager.
Preserve the existing managerNodes ordering and depth values expected by
ScopeSelect; if subtree sizes cannot be assigned during post-order without
changing that pre-order ordering, collect entries in the current pre-order and
populate teamSize in a separate pass.
src/frontend/src/lib/portal/portal-nav.ts (1)

88-92: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Normalize both sides of the root comparison before resetting direct.

patch.root is string | null and prev.scope is string | undefined. Two cases make the comparison report a move that did not happen:

  • The caller selects the viewer root (root: null) while prev.scope is already absent. null !== undefined is true, so direct resets.
  • validatePortalSearch lower-cases scope (portal-search.ts line 79), but ScopeSelect passes m.person_id verbatim. A person_id with any uppercase character never equals prev.scope, so direct resets on every pick.

Compare normalized values instead.

♻️ Proposed fix
           ...("root" in patch &&
           !("directOnly" in patch) &&
-          patch.root !== prev.scope
+          (patch.root?.toLowerCase() ?? null) !== (prev.scope ?? null)
             ? { direct: undefined }
             : {}),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/portal/portal-nav.ts` around lines 88 - 92, Normalize
both sides of the root comparison in the patch handling logic before deciding
whether to reset direct: treat null and undefined as the same viewer-root value,
and compare string roots case-insensitively to match validatePortalSearch and
ScopeSelect behavior. Preserve the existing direct reset for genuinely different
roots.
src/frontend/src/hooks/use-portal-period.ts (1)

30-37: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Stabilize the returned values and move the preference read out of the render body.

Two points on this block:

  • Line 30 reads localStorage during render whenever the URL names no period. That is an impure render-phase read of a mutable external store. Under concurrent rendering two components in the same pass can observe different values after setPeriod writes the preference. This module already has the correct primitive next door: portal-store.ts uses useSyncExternalStore. Expose the period preference through the same pattern.
  • Lines 31-37 allocate a new customRange and a new dateRange on every render. Consumers pass dateRange straight into useMetricCollection (src/frontend/src/components/portal/single-group-view.tsx line 38). Confirm no consumer lists dateRange or customRange in a useMemo, useEffect, or query-key dependency array, because a fresh identity there re-runs the effect or refetches on every render.

Wrap both in useMemo keyed on search.period, search.from, and search.to.

Run the following script to check whether any consumer depends on the identity of dateRange or customRange:

#!/bin/bash
# Description: Find consumers that place dateRange/customRange in a dependency array.
set -euo pipefail

# Locate every usePortalPeriod consumer.
rg -n --type=ts --type=tsx -C3 '\busePortalPeriod\s*\(' src/frontend/src || true

# Then look for dependency arrays that include the unstable values.
rg -nP --glob '*.ts' --glob '*.tsx' -C2 '\]\s*,\s*\[[^\]]*\b(dateRange|customRange)\b[^\]]*\]' src/frontend/src || true

# And effects/memos referencing them.
ast-grep run --pattern 'useEffect($_, [$$$, dateRange, $$$])' --lang tsx src/frontend/src || true
ast-grep run --pattern 'useMemo($_, [$$$, dateRange, $$$])' --lang tsx src/frontend/src || true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/hooks/use-portal-period.ts` around lines 30 - 37, Update
usePortalPeriod to expose the period preference through the module’s
useSyncExternalStore-based store pattern instead of reading localStorage during
render, reusing the existing preference subscription primitives. Memoize the
returned customRange and dateRange values with useMemo keyed by search.period,
search.from, and search.to, while preserving the current range-resolution
behavior and stable identities for consumers.
src/frontend/src/components/portal/slice-select.tsx (1)

22-23: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use a sentinel that cannot collide with a data-derived dimension key.

TEAM_KEY is the plain string "team". dims are derived from roster attributes, so a roster attribute named team produces a second entry with the same key in all. Two effects follow. React reports a duplicate key for the two SelectItem elements. setSlice then maps that real dimension to "", because line 41 treats any value equal to TEAM_KEY as "no slice".

Use a reserved sentinel that an attribute key cannot produce. The d.key || "team" fallback at line 55 is then unnecessary, because every entry in all has a non-empty key.

♻️ Proposed change
-const TEAM_KEY = "team";
+const TEAM_KEY = "__team__";
 const TEAM_SLICE = { key: TEAM_KEY, label: "Team (all)" };
-            <SelectItem key={d.key || "team"} value={d.key}>
+            <SelectItem key={d.key} value={d.key}>
#!/bin/bash
# Description: Determine whether availableSlices can emit the key "team".
set -euo pipefail

fd -t f 'slices.ts' -x ast-grep outline {} --items all
rg -n -C6 'availableSlices|PLANNED_SLICES|collectRosterAttrs' src/frontend/src/lib/insight/slices.ts
rg -n -C4 '"team"' src/frontend/src/lib/insight src/frontend/src/lib/portal

Also applies to: 34-37, 54-58

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/slice-select.tsx` around lines 22 - 23,
Replace the plain "team" value used by TEAM_KEY with a reserved sentinel that
cannot match any roster-derived dimension key, and update the
TEAM_SLICE/setSlice comparisons to use it consistently. Remove the d.key ||
"team" fallback when constructing all, since every entry must now retain a
non-empty key while preserving the team-wide selection behavior.
src/frontend/src/components/portal/people-view.tsx (1)

10-16: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Give the module guard a reset hook for tests.

lastRouteSync is module state with no way to clear it. The behavior is correct for a user session, and the comment explains why a ref is wrong here. The cost lands on tests. Vitest gives one module registry per test file, so the first case that mounts PeopleView with a given person consumes the guard. A later case in the same file that mounts the same person records no replaceScope call, and the failure looks like a bug in the component.

Export a reset function, or require vi.resetModules() in the suite. A reset is cheaper and makes the ordering dependency explicit.

♻️ Proposed change
 let lastRouteSync: string | null = null;
+
+/** Test-only: clear the cross-mount sync guard between cases. */
+export function resetRouteSyncGuard(): void {
+  lastRouteSync = null;
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/people-view.tsx` around lines 10 - 16,
Export a test-facing reset function near the module-scoped lastRouteSync guard
that sets lastRouteSync back to null. Keep the guard’s session behavior
unchanged, and make the reset callable by tests before mounting PeopleView so
repeated cases for the same person can independently verify route
synchronization.
src/frontend/src/components/portal/context-pane.tsx (1)

279-290: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hide the live group when it has no items, as ThemeNav does.

ThemeNav skips a group whose live list is empty (line 240). ItemsNav always renders the group. If showPlanned is false and every item in items is planned, the pane shows the groupLabel heading with no rows below it. Align the two renderers.

♻️ Proposed change
-      <SidebarGroup>
-        <SidebarGroupLabel>{groupLabel}</SidebarGroupLabel>
-        <SidebarGroupContent>
-          <SidebarMenu>
-            {live.map((it) => (
-              <ItemButton key={it.id} item={it} active={active === it.id} />
-            ))}
-          </SidebarMenu>
-        </SidebarGroupContent>
-      </SidebarGroup>
+      {live.length ? (
+        <SidebarGroup>
+          <SidebarGroupLabel>{groupLabel}</SidebarGroupLabel>
+          <SidebarGroupContent>
+            <SidebarMenu>
+              {live.map((it) => (
+                <ItemButton key={it.id} item={it} active={active === it.id} />
+              ))}
+            </SidebarMenu>
+          </SidebarGroupContent>
+        </SidebarGroup>
+      ) : null}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/context-pane.tsx` around lines 279 - 290,
Update the live-group render path in ItemsNav within context-pane.tsx so it
matches ThemeNav’s behavior when the live list is empty. Add an early
empty-state check before rendering the SidebarGroup/SidebarMenu for the live
items, and return nothing for that group when live has no entries. Keep the
existing ItemButton mapping and active selection logic unchanged for non-empty
live lists.
src/frontend/src/test/portal-router.tsx (1)

69-89: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Route the navigate search update through set so it strips undefined keys.

portalRouter.set deletes keys whose value is undefined (lines 43-45). The navigate mock assigns portalRouter.search directly and skips that step. A component that clears a portal key with navigate({ search: (p) => ({ ...p, item: undefined }) }) therefore leaves item: undefined on the recorded state.

Two consequences follow. A test that asserts the search object with toEqual sees an extra key. The mock also diverges from Link, which filters undefined at line 135, so the same navigation produces different state depending on which surface the test drives.

Reuse set for both branches.

♻️ Proposed change
     if (typeof opts.search === "function") {
-      portalRouter.search = (
-        opts.search as (p: unknown) => Record<string, unknown>
-      )(portalRouter.search) as PortalSearch;
-      emit();
+      portalRouter.set(
+        (opts.search as (p: unknown) => Record<string, unknown>)(
+          portalRouter.search,
+        ) as Partial<PortalSearch>,
+      );
     } else if (opts.search) {
-      portalRouter.search = opts.search as PortalSearch;
-      emit();
+      portalRouter.set(opts.search as Partial<PortalSearch>);
     }

Note: set merges, while a real navigate with an object replaces. If a test depends on replacement, clear the state first rather than reintroducing the direct assignment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/test/portal-router.tsx` around lines 69 - 89, Update the
navigate mock in portalRouterMock so both function and object search updates go
through portalRouter.set, ensuring undefined keys are removed. Preserve
navigate’s replacement semantics by clearing the existing search state before
applying an object update when necessary, rather than assigning
portalRouter.search directly.
src/frontend/src/components/portal/team-state-view.tsx (1)

89-104: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Correct the stale comment about GROUPS.

The comment states that GROUPS "is called INSIDE the memo — it returns a fresh array per call". Line 98 iterates GROUPS as a value. Two other files in this PR do the same: src/frontend/src/components/portal/single-group-view.tsx line 35 calls GROUPS.find(...), and src/frontend/src/components/portal/context-pane.tsx line 483 assigns const groups = GROUPS.

GROUPS is a module constant, so its identity is already stable and omitting it from the dependency list is correct. The reasoning in the comment is not. Update the text so a later reader does not act on the wrong premise.

♻️ Proposed change
-  // metric catalog across every group would blow past it. `GROUPS` is
-  // called INSIDE the memo — it returns a fresh array per call, and a fresh
-  // dependency would defeat the memo and re-key the grid query every render.
+  // metric catalog across every group would blow past it. `GROUPS` is a module
+  // constant, so it is read inside the memo and left out of the dependency
+  // list; the memo keeps one stable collection and one stable grid query key.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/team-state-view.tsx` around lines 89 -
104, Update the comment above headlineKeys and gridCollection to describe GROUPS
as a stable module-level constant whose identity does not need to appear in the
memo dependencies; remove the incorrect claim that it is called inside the memo
or returns a fresh array. Leave the useMemo implementation and dependency list
unchanged.
src/frontend/src/components/portal/zone-content.tsx (1)

52-56: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Give unknown zones neutral pending copy.

ZoneScaffold receives any zone that the switch does not handle, including an unrecognized zone from the URL. Line 53 assigns the reports copy to every zone that is not scorecard, so an unknown zone reads "Pending: diagnosis circuit + report builder". Line 61 already falls back to "Portal" for the title. Align the body copy with that fallback.

♻️ Proposed change
-  const pending =
-    zone === "scorecard"
-      ? "org snapshots + unit × quarter aggregation"
-      : "diagnosis circuit + report builder";
+  const PENDING: Record<string, string> = {
+    scorecard: "org snapshots + unit × quarter aggregation",
+    reports: "diagnosis circuit + report builder",
+  };
+  const pending = PENDING[zone] ?? "this lens";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/zone-content.tsx` around lines 52 - 56,
Update the pending-copy selection in ZoneScaffold so only "scorecard" uses the
scorecard text, the recognized reports zone uses the diagnosis/report text, and
unrecognized zones receive neutral pending copy aligned with the existing
"Portal" title fallback.
src/frontend/src/components/portal/section-trend.tsx (2)

53-108: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add unit tests for the alias remapping and the state branches.

The reviewed file list contains no section-trend.test.tsx. SectionTrend carries logic that deserves direct coverage: the safeKeys alias generation at Line 92, the row rewrite that drops null values at Line 94, the color cycling at Line 106, and the pending, error, and empty branches at Lines 65 to 86.

Run the following script to confirm whether coverage exists elsewhere:

#!/bin/bash
# Description: Locate tests that exercise SectionTrend.
fd -t f 'section-trend' src/frontend/src
rg -n --type=tsx -C 3 'SectionTrend' -g '*.test.tsx' src/frontend/src

As per coding guidelines: "Ensure new and changed lines achieve at least 80% test coverage."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/section-trend.tsx` around lines 53 - 108,
Add focused unit tests for SectionTrend covering pending, error with retry,
empty-data, and rendered-data states. Verify alias generation for dotted and
special-character series keys, row rewriting including omission of null values,
and color cycling across DEFAULT_CHART_KEYS; place tests in a section-trend test
file and ensure the changed logic reaches at least 80% coverage.

139-167: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Derive the right axis from the series instead of trusting the prop.

Line 166 honors s.yAxisId === "right", but Line 139 renders the right <YAxis> only when the caller passes rightAxis. A caller that sets yAxisId: "right" on a series and omits rightAxis produces a series bound to an axis that does not exist. The current caller in src/frontend/src/components/portal/domain-lens-view.tsx at Line 652 derives both from the same list, so the production path is consistent. The component type does not enforce that pairing.

♻️ Proposed refactor
-            {rightAxis ? (
+            {rightAxis || series.some((s) => s.yAxisId === "right") ? (
               <YAxis
                 yAxisId="right"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/section-trend.tsx` around lines 139 - 167,
Update the SectionTrend axis setup so the right <YAxis> is derived from
safeSeries instead of relying only on the rightAxis prop. Use the existing
yAxisId logic in the SectionTrend map to detect when any series targets "right",
and render the right axis whenever that condition is present. Keep the current
series-to-axis binding in SectionTrend unchanged so s.yAxisId continues to drive
the chart. Ensure the rightAxis prop no longer has to be manually paired with
series using "right", while preserving existing behavior when no series needs
the right axis.
src/frontend/src/components/portal/domain-lens-view.tsx (1)

447-464: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Memoize the per-bucket active-people aggregation.

ParticipationSection rebuilds byDate on every render. The loop is triple nested over spec.metrics, memberIds, and every timeseries point. At org scope this repeats a large scan for each parent re-render, for example on a slice or period change.

♻️ Proposed refactor
-  const byDate = new Map<string, Set<string>>();
-  for (const key of spec.metrics) {
-    const r = trend.byKey.get(key);
-    if (!r) continue;
-    for (const id of memberIds) {
-      for (const s of forEntity(r, id).series) {
-        for (const p of s.points) {
-          if ((p.value ?? 0) > 0) {
-            (byDate.get(p.bucket_start) ?? byDate.set(p.bucket_start, new Set()).get(p.bucket_start)!).add(id);
-          }
-        }
-      }
-    }
-  }
-  const data = [...byDate.entries()]
-    .map(([date, ids]) => ({ date, active: ids.size }))
-    .sort((a, b) => a.date.localeCompare(b.date));
+  const data = useMemo(() => {
+    const byDate = new Map<string, Set<string>>();
+    for (const key of spec.metrics) {
+      const r = trend.byKey.get(key);
+      if (!r) continue;
+      for (const id of memberIds) {
+        for (const s of forEntity(r, id).series) {
+          for (const p of s.points) {
+            if ((p.value ?? 0) > 0) {
+              let set = byDate.get(p.bucket_start);
+              if (!set) {
+                set = new Set<string>();
+                byDate.set(p.bucket_start, set);
+              }
+              set.add(id);
+            }
+          }
+        }
+      }
+    }
+    return [...byDate.entries()]
+      .map(([date, ids]) => ({ date, active: ids.size }))
+      .sort((a, b) => a.date.localeCompare(b.date));
+  }, [spec.metrics, trend.byKey, memberIds]);

Move the useMemo above the if (memberIds.length === 0) return null; guard at Line 445 to keep the hook order stable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/domain-lens-view.tsx` around lines 447 -
464, Memoize the per-bucket active-people aggregation inside
ParticipationSection so the byDate/data computation is not rebuilt on every
render. Move the useMemo for the byDate scan above the early return on
memberIds.length === 0 to keep hook order stable, and reuse the existing
spec.metrics, memberIds, trend.byKey, and forEntity inputs as dependencies so
the aggregated result only recomputes when those inputs change.

Comment on lines +377 to +384
// A lens we simply have not built yet is hidden unless the viewer asked for
// planned work; a lens waiting on the product stays listed (dimmed) because
// it tells the reader the domain exists in our model.
const lenses = direction.lenses.filter((lens) => {
const entry = lensEntry(direction.id, lens);
if (!entry || !("comingSoon" in entry)) return true;
return entry.readiness === "planned" || showPlanned;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The readiness filter looks inverted relative to its comment.

The comment states two rules. A lens that is not built yet is hidden unless the viewer asked for planned work. A lens waiting on the product stays listed.

The predicate does the reverse. For a roadmap entry it returns true when entry.readiness === "planned", so a planned lens is always visible. Every other roadmap readiness is visible only when showPlanned is true.

The rest of this file treats planned as the gated, demoted state: partitionByReadiness(items, showPlanned) at lines 235 and 278, PLANNED_GROUP_LABEL, and the planned prop on ItemButton. If that vocabulary holds here, the test should be entry.readiness !== "planned" || showPlanned.

This also changes the default lens. Line 391 seeds setLens(lenses[0] ?? direction.lenses[0]!) from the filtered list, so a wrong filter selects a wrong lens when the reader expands a direction.

Confirm the readiness union and the intended gate before changing the condition.

#!/bin/bash
# Description: Resolve the lens readiness contract and compare it with the pane filter.
set -euo pipefail

# The lens entry shape and readiness union.
fd -t f 'lens-configs.ts' -x ast-grep outline {} --items all

# Every readiness literal in the portal libs.
rg -n --type=ts 'readiness' src/frontend/src/lib | head -50

# The shared gate the rest of the pane uses.
ast-grep run --pattern 'function partitionByReadiness($$$) { $$$ }' --lang typescript src/frontend/src/lib/portal/nav-model.ts

# Existing expectations, if any, for this filter.
rg -n -C4 'comingSoon|readiness' src/frontend/src/lib/portal/lens-configs.test.ts
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/components/portal/context-pane.tsx` around lines 377 - 384,
Verify the lens readiness union and existing partitionByReadiness contract, then
update the filter inside the lenses computation to gate only planned entries:
non-planned lenses remain visible by default, while planned lenses appear only
when showPlanned is true. Preserve the downstream lenses[0] default-selection
behavior and align it with the shared readiness vocabulary.

Comment thread src/frontend/src/components/portal/person-header.tsx
Comment thread src/frontend/src/hooks/use-portal-period.ts Outdated
Comment on lines +23 to +25
for (const id of memberIds) {
const bins = forEntity(result, id).histogram[0]?.bins;
if (!bins?.length) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject partial histograms.

A requested member with no bins is skipped at Line 25. The function then returns totals for only a subset of memberIds. This conflicts with the documented all-entity compatibility contract.

  • src/frontend/src/lib/portal/event-histogram.ts#L23-L25: Return null when any requested member has no non-empty histogram bins.
  • src/frontend/src/lib/portal/event-histogram.test.ts#L21-L30: Add a case with one populated member and one requested member without bins. Expect null.
Proposed fix
 for (const id of memberIds) {
   const bins = forEntity(result, id).histogram[0]?.bins;
-  if (!bins?.length) continue;
+  if (!bins?.length) return null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const id of memberIds) {
const bins = forEntity(result, id).histogram[0]?.bins;
if (!bins?.length) continue;
for (const id of memberIds) {
const bins = forEntity(result, id).histogram[0]?.bins;
if (!bins?.length) return null;
📍 Affects 2 files
  • src/frontend/src/lib/portal/event-histogram.ts#L23-L25 (this comment)
  • src/frontend/src/lib/portal/event-histogram.test.ts#L21-L30
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/portal/event-histogram.ts` around lines 23 - 25, Update
the member iteration in forEntity/event histogram aggregation to return null
immediately when any requested member has no non-empty bins, instead of skipping
it; preserve aggregation only when every member is represented. In
src/frontend/src/lib/portal/event-histogram.test.ts lines 21-30, add coverage
with one populated member and one requested member without bins, asserting the
result is null.

Comment on lines +37 to +42
{
kind: "participation",
metrics: ["ai.active_days"],
title: "AI adoption",
noun: "People using AI",
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Remove static metric semantics from this configuration.

noun, caption, and unitLabel define user-facing meaning for API metrics in the frontend. Derive these values from the metric definition response in the renderer. Do not describe ai.active_days as AI adoption or define git.commits units as “commits per person” here.

As per coding guidelines, “Use metric names, descriptions, and units supplied by API responses; do not invent user-facing metric semantics in the frontend.”

Also applies to: 78-84

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/portal/overview-configs.ts` around lines 37 - 42, Remove
the static user-facing metric semantics from the participation configuration and
the corresponding git.commits configuration, including noun, caption, and
unitLabel values. Update the renderer that consumes these configs to derive
those fields from the API metric definition response, while preserving the
metric names and existing configuration structure.

Source: Coding guidelines

Comment thread src/frontend/src/lib/portal/portal-nav.ts
Comment thread src/frontend/src/lib/portal/portal-store.ts
Comment on lines +56 to +83
const portal = usePortalEnabled();
const pathname = useRouterState({ select: (s) => s.location.pathname });
// The portal is a ROUTE now, so it renders through the Outlet like anything
// else — otherwise its navigation could never live in the URL. It still owns
// the whole shell on the routes it claims (/portal and the person pages);
// everywhere else (/metrics, /whats-new) the app chrome stays, which the
// old "portal replaces the app" branch used to swallow.
const portalRoute =
pathname === "/portal" || /^\/ic\/[^/]+\/(personal|team)\/?$/.test(pathname);
return (
<TooltipProvider>
{/* Upstream's evidence-dialog provider wraps everything; the portal
branch lives inside it, so a drilldown opened from a portal surface
finds the same provider the legacy screens use. */}
<MetricEvidenceDialogProvider>
<AuthGate>
<SidebarProvider>
<AppSidebar />
<SidebarInset className="min-w-0 overflow-x-clip">
<MockBanner />
<ViewAsBanner />
<Outlet />
</SidebarInset>
</SidebarProvider>
{portal && portalRoute ? (
<PortalLayout />
) : (
<SidebarProvider>
<AppSidebar />
<SidebarInset className="min-w-0 overflow-x-clip">
<MockBanner />
<ViewAsBanner />
<Outlet />
</SidebarInset>
</SidebarProvider>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Move portal routing behavior out of route files.

These route files now contain shell selection, search handling, and redirect behavior. Keep route files as thin screen wrappers.

  • src/frontend/src/routes/__root.tsx#L56-L83: move portal shell selection into a screen or routing adapter outside src/routes/.
  • src/frontend/src/routes/ic.$person.tsx#L8-L18: move portal search handling into the portal navigation layer.
  • src/frontend/src/routes/index.tsx#L8-L14: move the portal landing redirect into the portal navigation layer.
  • src/frontend/src/routes/portal.tsx#L16-L25: wrap a portal screen component only.

As per coding guidelines, “Keep route files under src/routes/ thin: they should only wrap a screen component and contain no additional logic.”

📍 Affects 4 files
  • src/frontend/src/routes/__root.tsx#L56-L83 (this comment)
  • src/frontend/src/routes/ic.$person.tsx#L8-L18
  • src/frontend/src/routes/index.tsx#L8-L14
  • src/frontend/src/routes/portal.tsx#L16-L25
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/routes/__root.tsx` around lines 56 - 83, Move portal shell
selection from src/frontend/src/routes/__root.tsx lines 56-83 into a screen or
routing adapter outside src/routes/, preserving the PortalLayout, app-shell, and
route matching behavior. Move portal search handling from
src/frontend/src/routes/ic.$person.tsx lines 8-18 and the portal landing
redirect from src/frontend/src/routes/index.tsx lines 8-14 into the portal
navigation layer. Update src/frontend/src/routes/portal.tsx lines 16-25 to only
wrap and render a portal screen component; keep all route files as thin screen
wrappers without additional navigation or shell logic.

Source: Coding guidelines

Comment thread src/frontend/src/routes/__root.tsx
… paths

Review round on the monorepo PR. Six real findings, all in code this branch
added:

- The teammate switcher in the person header still navigated by email, so
  picking a peer opened a page whose metrics resolved to nobody. It now
  passes `person_id` like every other link — a leftover from the identity
  cutover, and the one bug in that migration.
- /portal rendered the portal through the Outlet even with the preview flag
  off, because the root shell only swaps in PortalLayout when the flag is
  on. A pasted URL (or turning the preview off while standing there) painted
  the portal inside the app chrome. The route now redirects home.
- `setCustomRange` threw on an invalid range from inside an event handler,
  where no error boundary catches it. It drops the range instead — the same
  degrade-not-error policy `validatePortalSearch` promises.
- `decodeURIComponent` on the path segment raises URIError on a malformed
  percent-sequence, crashing the shell during render. All three sites now go
  through `personIdFromPath`, which falls back to "no person".
- The portal store read localStorage unguarded at module scope, so blocked
  storage (sandboxed iframe, third-party cookie blocking) threw a
  SecurityError before any component mounted. Guarded like the write path.
- `usePortalScope` returned a fresh object every render, defeating
  `useOrgScope`'s memo and re-walking the identity tree on every render of
  every org zone. Memoised on the two primitives.

Also moved the portal's shell-path predicate out of __root into
`lib/portal/portal-routes`, so the route file asks rather than decides.

Two findings are not bugs and are answered on the PR instead: the lens
readiness filter matches `partitionByReadiness` (`planned` is roadmap the
reader benefits from seeing, `unbuilt` is ours and gated), and skipping a
member with no histogram bins is deliberate — they had no events, and
bailing would blank the chart for the whole org whenever one person was
inactive. The histogram's doc comment now says so outright.

Signed-off-by: Alexey Panfilov <alexey.panfilov@constructor.tech>
@dzarlax

dzarlax commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — six of the eight are real and fixed in e6f11cd9. Two are not bugs; details below.

Fixed

  • person-header.tsx — teammate navigation by email. Correct, and the one bug in the identity-cutover migration: goPerson and goTeam were converted, the peers dropdown was not. Now p.person_id, with the comparison and the menu key on the id too.
  • /portal renders with the preview off. Correct and worse than it looks: the root shell only swaps in PortalLayout while the flag is on, so with it off the route mounted the portal inside the app chrome. The route component now redirects home, with tests for both states (src/routes/portal.test.tsx).
  • setCustomRange throws from an event handler. Fixed: it drops an invalid range, matching the policy validatePortalSearch states. The picker keeps its own validation message.
  • decodeURIComponent on the path segment. Fixed at all three sites (use-active-zone, app-sidebar, org-tree did the same raw decode) via one personIdFromPath in lib/metrics/entity.ts, which returns null on a malformed sequence so callers fall back to the viewer.
  • Unguarded localStorage reads. Fixed: one guarded readKey, since these run at module scope and a SecurityError there takes the bundle down over a preview flag. Covered by a test that makes getItem throw.
  • usePortalScope identity. Fixed with useMemo on the two primitives; the quadratic re-walk you describe was the real cost.

Partly taken: route files should stay thin. Moved the shell-path predicate out of __root.tsx into lib/portal/portal-routes.ts, so the route file asks instead of deciding. The rest I would keep: validateSearch and the retainSearchParams middleware are route configuration that TanStack requires on the route, and the isPersonId / preview-flag guards follow the pattern already in routes/ic.$person.personal.tsx upstream. __root.tsx is the app shell rather than a screen wrapper, so a shell/chrome decision there is where it belongs.

Not a bug: the lens readiness filter. The vocabulary is the other way round from your reading, and it matches the shared helper: in partitionByReadiness (lib/portal/nav-model.ts line 184) planned is roadmap the reader benefits from seeing — always listed, dimmed — while unbuilt is our own backlog and appears only when the viewer asked for planned work. entry.readiness === "planned" || showPlanned says exactly that, and the comment above it describes the same two rules.

Not a bug: partial event histograms. Skipping a member with no bins is deliberate: they had no events in the period, which is a normal reading on any real roster. Returning null there would blank the org chart whenever one person was inactive — the opposite of the intent. The compatibility contract is about bin edges, which is still enforced (differing edges or an anomalous bin count return null). The doc comment now says both things outright, since the old wording was what made this ambiguous.

@dzarlax

dzarlax commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Correcting myself on the overview-configs point above: I wrote that the API has no field for this. That is wrong, and it makes your finding bigger rather than smaller.

The registry carries 59 metrics, all 59 with description and explanation, 46 with unit (registry.yaml), /v1/metric-results ships both (builder.rs#L286), and the frontend already parses them (collection.ts#L143-L144). The portal's section renderer uses neither, while other screens do (metric-summary-card, kpi-row, peer-story). So the config is not just writing copy the catalog owns — the portal is also dropping a populated field the reader needs. On meeting hours the explanation carries the caveat that Zoom reports full-session estimates and runs higher than Teams, which is exactly what someone comparing two teams has to know, and we show it nowhere.

Three of the four fields are derivable, one is not:

Field Verdict
title derivable from the metric label plus the section kind
caption, first clause ("how many people fall in each commit-count band") derivable the same way
caption, second clause ("a long right tail means a few people produce most of the commits") genuinely editorial — it is about the chart shape, not the metric, and no API field carries it
unitLabel derivable from unit, with a fallback: 13 of 59 metrics have no unit, and h expands to "h per person" where the hand-written label reads "meeting hours per person"

I have not changed it in this PR, because the honest version of the fix is "the portal shows explanation", and that is a new affordance with a placement decision rather than a config cleanup — doing it here would either sprawl the PR or ship the cosmetic half. Filed as #2242 with the three steps in order, and the interpretive caption sentence is the only metric copy that stays in config afterwards.

The first pass only answered the nine findings posted inline; the review body
carried another 12 minor and 21 nitpick items. Going through them, most were
right.

Correctness and copy:

- formatAxisTick printed "1000k" for 999_600 — a tick naming a magnitude the
  scale below it already covers. Rolls over to M now.
- The distribution binned a negative value into counts[-1], which lands on a
  property outside the array, so the person vanished from the histogram and
  the bars stopped summing to the population. Clamped at both ends.
- fmtCompact stopped at thousands, so a million-level bin edge read "1000k".
  It keeps its own ladder rather than delegating to formatAxisTick: a bin edge
  is an exact value off the 1/2/5 ladder, while a tick marks a gridline, so
  the two round differently on purpose.
- "All 1 people are within their usual range" — a scope of one is a real case
  (a lead with one report). Both attention sentences pluralise now.
- The members-table caption interpolated an empty scope label, so the
  accessible name for the table started with a dash.
- Average AI cost hardcoded currency and USD while the tile above it already
  read format and unit off the metric result.
- The cohort label lowercased any dimension label. Fine for "Division", wrong
  the moment identity exposes attributes generically (constructorfabric#1881) and a label is
  "R&D area". Only a plain capitalised word is lowered.
- The slice sentinel was the plain string "team", so a roster attribute named
  `team` collided with it: duplicate React key, and picking the real
  dimension read as "no slice". It is `__team__` now.
- An unrecognised zone from the URL rendered the reports scaffold copy, so it
  claimed pending work that has nothing to do with it.
- The compact date-range trigger loses its label below `sm` and had no
  accessible name at all — it is icon-only there.
- useViewerIsManager answered "not a manager" when identity failed, which
  demotes the viewer to an IC shell instead of letting the org zone show the
  identity error. Unresolved until a tree arrives.
- Two person-id comparisons were case-sensitive while the rest of the app
  normalises: the route→scope sync re-fired on a casing difference, and
  setScope treated a same-root move as a new root and dropped direct-only.
- An empty live group in the pane rendered a heading with nothing under it,
  which reads as a load failure rather than as a filter (ThemeNav already
  skipped it).

Performance:

- The manager-node walk called flattenSubordinates per manager, re-walking
  that subtree — O(n · depth). One pass now returns each subtree size, with
  the entry pushed before recursing so the picker keeps its outline order.
  Test added: post-order would have reordered it into depth bands.
- The participation section rebuilt its per-bucket active-people scan
  (metrics × roster × buckets) on every parent render.
- usePortalPeriod read localStorage in the render body — an impure read of a
  mutable store, where two components in one concurrent pass can disagree
  after a write. The preference is subscribed through the store that already
  exists next door, and the returned range and callbacks are stable.

Tests:

- section-trend had no test file. Ten cases: the CSS-safe alias remapping on
  both series and rows, nulls left as gaps, right-axis derivation, stacking,
  and the pending / error / empty branches.
- shell-layout mocked useViewerIsManager twice, so the state-controlled
  factory was dead. It was scaffolding rather than a masked failure — the IC
  cases live in portal-shell — but the override is gone.
- domain-lens-view's collection mock kept a counter across the whole file, so
  each caller's slot depended on how many renders earlier tests happened to
  do. Reset per test.
- The retry spies in employees-view and manage-view accumulated across the
  file, so toHaveBeenCalledOnce held only while exactly one test triggered a
  retry.
- portal-shell claimed to test a remount while mounting two live instances.
  It unmounts first now, and its person fixtures are ids.
- attention-list minted its own person ids with a helper that broke the UUID
  shape past nine; it uses the shared `pid`.
- The router fake assigned search directly, keeping cleared keys as
  `undefined` where the real router drops them — it routes through `set`.
- The route→scope guard moved to lib/portal/route-scope-sync with a reset, so
  a second test mounting the same person is not silently a no-op.

Two were not bugs. The portal-store and team-state comments were stale rather
than wrong in code, and are corrected in place.

Signed-off-by: Alexey Panfilov <alexey.panfilov@constructor.tech>
@dzarlax

dzarlax commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

I had only answered the nine inline findings — the review body carried another 12 minor and 21 nitpick items, which I missed. Worked through all of them in 29021264. Most were right; the ones I skipped are named at the end.

Correctness and copy

  • formatAxisTick printed 1000k for 999_600 — a tick naming a magnitude the scale below it already covers. Rolls over to M.
  • distribution binned a negative value into counts[-1], a property outside the array, so the person disappeared from the histogram and the bars stopped summing to the population.
  • fmtCompact stopped at thousands, so a million-level bin edge read 1000k. Fixed with its own million step rather than by delegating to formatAxisTick — a bin edge is an exact value off the 1/2/5 ladder, a tick marks a gridline, so the two round differently on purpose. Delegating would have made a 1500 bin read 1500.
  • "All 1 people are within their usual range" — a lead with one report is a real scope. Both attention sentences pluralise.
  • The members-table caption interpolated an empty scope label, so the table's accessible name began with a dash.
  • Average AI cost hardcoded currency / USD while the tile directly above already read format and unit off the metric result.
  • The cohort label lowercased any dimension label. Harmless for "Division", wrong the moment identity exposes attributes generically (Identity: expose person attributes generically for dynamic slicing / cohorts #1881) and a label is "R&D area" — only a plain capitalised word is lowered now.
  • The slice sentinel was the bare string "team", so a roster attribute named team collided with it: duplicate React key, and picking the real dimension read as "no slice".
  • An unrecognised zone from the URL rendered the reports scaffold copy, claiming pending work unrelated to it.
  • The compact date-range trigger is icon-only below sm and had no accessible name.
  • useViewerIsManager answered "not a manager" when identity failed, demoting the viewer to an IC shell instead of letting the org zone show the identity error. Unresolved until a tree arrives.
  • Two person-id comparisons were case-sensitive while the rest of the app normalises: the route→scope sync re-fired on a casing difference, and setScope read a same-root move as a new root and dropped direct-only.
  • An empty live group in the pane rendered a heading with nothing under it, which reads as a load failure rather than a filter — ThemeNav already skipped it.

Performance

  • The manager-node walk called flattenSubordinates per manager, re-walking that subtree: O(n · depth). One pass returns each subtree size, with the entry pushed before recursing so the picker keeps its outline order — a post-order walk would have reordered it into depth bands, so there is now a test pinning the order across two branches.
  • The participation section rebuilt its metrics × roster × buckets scan on every parent render.
  • usePortalPeriod read localStorage in the render body. The preference is now subscribed through the external store that already existed in use-period.ts, and the returned range and callbacks are stable — they land in the dependency arrays of every metric query.

Tests

  • section-trend had no test file at all. Ten cases: the CSS-safe alias remapping on both series and rows, nulls left as gaps, right-axis derivation, stacking, and the pending / error / empty branches.
  • shell-layout mocked useViewerIsManager twice, so the state-controlled factory was dead. Worth noting it was scaffolding rather than a masked failure — nothing in that file sets the flag false, and the IC cases live in portal-shell — but the override is gone.
  • domain-lens-view's collection mock kept a counter across the whole file, so each caller's slot depended on how many renders earlier tests happened to do.
  • The retry spies in employees-view and manage-view accumulated across their files, so toHaveBeenCalledOnce held only while exactly one test triggered a retry.
  • portal-shell claimed to test a remount while mounting two live instances; it unmounts first now, and its person fixtures are ids.
  • attention-list minted person ids with a local helper that broke its own UUID shape past nine — it uses the shared pid.
  • The router fake assigned search directly, keeping cleared keys as undefined where the real router drops them and Link filters them. It routes through set.
  • The route→scope guard moved to lib/portal/route-scope-sync with a reset, so a later test mounting the same person is not silently a no-op. It needed its own module anyway: exporting a helper from a component file breaks fast refresh.

Not taken

  • The lens readiness filter and the partial-histogram skip, for the reasons in my earlier comment.
  • overview-configs static metric semantics: filed as frontend: portal sections hide the catalog's metric explanation and hand-write captions instead #2242 with a three-step plan, and I posted a correction above — the API does carry description and explanation, and the portal renders neither, which makes the finding bigger than a config cleanup.
  • The stale portal-store and team-state comments were wrong text over correct code; corrected in place.

Verified on the ported tree: pnpm typecheck clean, eslint --max-warnings 0 clean, 810 unit tests pass (823 in the source branch, which includes the Storybook browser suites).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/frontend/src/lib/portal/portal-nav.ts (1)

78-82: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset direct when replaceScope changes the root.

If the current URL has direct=true, replaceScope({ root }) keeps that filter for the new root. PeopleView uses this action during route-to-scope synchronization. The new team can then show direct reports only instead of its full roster.

Apply the same normalized previous-root comparison that setScope uses. Add a regression test for changing from scope=A&direct=true to scope=B.

Proposed fix
       replaceScope: (patch) =>
         setSearch(
-          { ...("root" in patch ? { scope: patch.root ?? undefined } : {}) },
+          (prev) => ({
+            ...("root" in patch ? { scope: patch.root ?? undefined } : {}),
+            ...("root" in patch &&
+            normalizePersonId(patch.root ?? "") !==
+              normalizePersonId(prev.scope ?? "")
+              ? { direct: undefined }
+              : {}),
+          }),
           { replace: true },
         ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/portal/portal-nav.ts` around lines 78 - 82, Update the
replaceScope action in portal-nav.ts so it uses the same normalized
previous-root comparison as setScope and clears the direct filter when the root
actually changes. Keep the existing scope update behavior, but ensure a
transition like scope=A&direct=true to scope=B does not preserve direct=true.
Add a regression test covering this replaceScope root-change case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/frontend/src/hooks/use-portal-period.test.tsx`:
- Around line 21-30: Reset the module-level period preference in the test
beforeEach alongside portalRouter and localStorage cleanup. Use the reset
mechanism exposed by use-period.ts (or add a test-only reset if none exists) so
each usePortalPeriod test starts with no stored period and cannot inherit the
"month" value from earlier tests.

In `@src/frontend/src/lib/portal/route-scope-sync.ts`:
- Around line 14-29: The claimScopeSync guard only remembers the latest person,
allowing a previously synchronized person to be claimed again. Replace
lastSynced with a Set<string> of synchronized person IDs, have claimScopeSync
reject any ID already in the set and record new IDs, and clear the set in
resetRouteScopeSync. Add coverage for the A → B → A sequence, verifying the
final A claim returns false.

---

Outside diff comments:
In `@src/frontend/src/lib/portal/portal-nav.ts`:
- Around line 78-82: Update the replaceScope action in portal-nav.ts so it uses
the same normalized previous-root comparison as setScope and clears the direct
filter when the root actually changes. Keep the existing scope update behavior,
but ensure a transition like scope=A&direct=true to scope=B does not preserve
direct=true. Add a regression test covering this replaceScope root-change case.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bc833476-4df0-4ca3-a461-6a620ecea4d7

📥 Commits

Reviewing files that changed from the base of the PR and between e3823f6 and 2902126.

📒 Files selected for processing (44)
  • src/frontend/src/components/app-sidebar.tsx
  • src/frontend/src/components/org-tree.tsx
  • src/frontend/src/components/portal/ai-cost-view.tsx
  • src/frontend/src/components/portal/attention-list.test.tsx
  • src/frontend/src/components/portal/context-pane.tsx
  • src/frontend/src/components/portal/domain-lens-view.test.tsx
  • src/frontend/src/components/portal/domain-lens-view.tsx
  • src/frontend/src/components/portal/employees-view.test.tsx
  • src/frontend/src/components/portal/manage-view.test.tsx
  • src/frontend/src/components/portal/people-view.tsx
  • src/frontend/src/components/portal/person-header.tsx
  • src/frontend/src/components/portal/portal-shell.test.tsx
  • src/frontend/src/components/portal/section-trend.test.tsx
  • src/frontend/src/components/portal/section-trend.tsx
  • src/frontend/src/components/portal/shell-layout.test.tsx
  • src/frontend/src/components/portal/slice-select.tsx
  • src/frontend/src/components/portal/team-state-view.tsx
  • src/frontend/src/components/portal/zone-content.tsx
  • src/frontend/src/components/widgets/period-selector-bar.tsx
  • src/frontend/src/hooks/use-period.ts
  • src/frontend/src/hooks/use-portal-period.test.tsx
  • src/frontend/src/hooks/use-portal-period.ts
  • src/frontend/src/lib/format.ts
  • src/frontend/src/lib/insight/attention-flags.ts
  • src/frontend/src/lib/metrics/entity.test.ts
  • src/frontend/src/lib/metrics/entity.ts
  • src/frontend/src/lib/portal/event-histogram.ts
  • src/frontend/src/lib/portal/metric-stats.test.ts
  • src/frontend/src/lib/portal/metric-stats.ts
  • src/frontend/src/lib/portal/portal-nav.ts
  • src/frontend/src/lib/portal/portal-routes.test.ts
  • src/frontend/src/lib/portal/portal-routes.ts
  • src/frontend/src/lib/portal/portal-store.test.ts
  • src/frontend/src/lib/portal/portal-store.ts
  • src/frontend/src/lib/portal/route-scope-sync.ts
  • src/frontend/src/lib/portal/use-active-zone.ts
  • src/frontend/src/lib/portal/use-cohort-label.ts
  • src/frontend/src/lib/portal/use-org-scope.test.ts
  • src/frontend/src/lib/portal/use-org-scope.ts
  • src/frontend/src/lib/portal/use-viewer-is-manager.ts
  • src/frontend/src/routes/__root.tsx
  • src/frontend/src/routes/portal.test.tsx
  • src/frontend/src/routes/portal.tsx
  • src/frontend/src/test/portal-router.tsx
💤 Files with no reviewable changes (1)
  • src/frontend/src/components/portal/shell-layout.test.tsx
🚧 Files skipped from review as they are similar to previous changes (30)
  • src/frontend/src/lib/format.ts
  • src/frontend/src/routes/portal.tsx
  • src/frontend/src/components/portal/employees-view.test.tsx
  • src/frontend/src/components/portal/manage-view.test.tsx
  • src/frontend/src/components/portal/people-view.tsx
  • src/frontend/src/lib/portal/portal-store.test.ts
  • src/frontend/src/components/app-sidebar.tsx
  • src/frontend/src/components/portal/slice-select.tsx
  • src/frontend/src/lib/portal/use-active-zone.ts
  • src/frontend/src/components/widgets/period-selector-bar.tsx
  • src/frontend/src/lib/portal/use-viewer-is-manager.ts
  • src/frontend/src/routes/__root.tsx
  • src/frontend/src/test/portal-router.tsx
  • src/frontend/src/lib/portal/event-histogram.ts
  • src/frontend/src/components/portal/section-trend.tsx
  • src/frontend/src/hooks/use-portal-period.ts
  • src/frontend/src/components/portal/attention-list.test.tsx
  • src/frontend/src/components/portal/zone-content.tsx
  • src/frontend/src/components/org-tree.tsx
  • src/frontend/src/lib/portal/use-org-scope.ts
  • src/frontend/src/lib/portal/use-cohort-label.ts
  • src/frontend/src/lib/insight/attention-flags.ts
  • src/frontend/src/components/portal/portal-shell.test.tsx
  • src/frontend/src/lib/portal/use-org-scope.test.ts
  • src/frontend/src/lib/portal/portal-store.ts
  • src/frontend/src/lib/portal/metric-stats.test.ts
  • src/frontend/src/components/portal/context-pane.tsx
  • src/frontend/src/components/portal/ai-cost-view.tsx
  • src/frontend/src/lib/portal/metric-stats.ts
  • src/frontend/src/components/portal/domain-lens-view.tsx

Comment thread src/frontend/src/hooks/use-portal-period.test.tsx
Comment on lines +14 to +29
let lastSynced: string | null = null;

/**
* True at most once per person: the caller may sync the scope, and the guard
* records that it did.
*/
export function claimScopeSync(personId: string): boolean {
if (!personId || lastSynced === personId) return false;
lastSynced = personId;
return true;
}

/** Tests only — see the note above. */
export function resetRouteScopeSync(): void {
lastSynced = null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Track every synchronized person.

lastSynced only remembers the latest person. For A → B → A, claimScopeSync("A") returns true twice. The second visit can overwrite a scope that the user selected after the first visit.

Use a Set<string> for synchronized person IDs. Clear the set in resetRouteScopeSync. Add a test for A → B → A.

Proposed fix
-let lastSynced: string | null = null;
+const syncedPeople = new Set<string>();
 
 export function claimScopeSync(personId: string): boolean {
-  if (!personId || lastSynced === personId) return false;
-  lastSynced = personId;
+  if (!personId || syncedPeople.has(personId)) return false;
+  syncedPeople.add(personId);
   return true;
 }
 
 export function resetRouteScopeSync(): void {
-  lastSynced = null;
+  syncedPeople.clear();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let lastSynced: string | null = null;
/**
* True at most once per person: the caller may sync the scope, and the guard
* records that it did.
*/
export function claimScopeSync(personId: string): boolean {
if (!personId || lastSynced === personId) return false;
lastSynced = personId;
return true;
}
/** Tests only — see the note above. */
export function resetRouteScopeSync(): void {
lastSynced = null;
}
let syncedPeople = new Set<string>();
/**
* True at most once per person: the caller may sync the scope, and the guard
* records that it did.
*/
export function claimScopeSync(personId: string): boolean {
if (!personId || syncedPeople.has(personId)) return false;
syncedPeople.add(personId);
return true;
}
/** Tests only — see the note above. */
export function resetRouteScopeSync(): void {
syncedPeople.clear();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/lib/portal/route-scope-sync.ts` around lines 14 - 29, The
claimScopeSync guard only remembers the latest person, allowing a previously
synchronized person to be claimed again. Replace lastSynced with a Set<string>
of synchronized person IDs, have claimScopeSync reject any ID already in the set
and record new IDs, and clear the set in resetRouteScopeSync. Add coverage for
the A → B → A sequence, verifying the final A claim returns false.

…-sync rule

Second review round on the monorepo PR, plus docstrings where they carry
something.

- use-portal-period.test cleared localStorage but not the in-memory period
  store next to it, so the first test's "month" became the default every
  later test inherited when the URL named no period.
- The route→scope guard now has its own test, including the A → B → A case
  the review flagged. Keeping only the LATEST person is deliberate rather
  than an oversight: arriving at a person's team is a navigation, so a reader
  who visits A, re-scopes, looks at B and clicks back to A's team has to get
  A's roster. Remembering every person ever seen would leave the scope on B
  while the route said A — an address disagreeing with the screen, which is
  the thing this migration set out to remove. The module comment says so now,
  and four cases pin it.
- Docstrings on the exported symbols where a line of prose adds information:
  what an absent URL param means, what the scope picker's shapes are, why a
  trend point omits a series key rather than zeroing it, and the guard/store
  contracts. Deliberately not on the API-client types that mirror the server
  DTO field-for-field, nor on upstream code this branch only touched — a
  docstring restating the symbol's own name is noise that ages badly.

Signed-off-by: Alexey Panfilov <alexey.panfilov@constructor.tech>
@dzarlax

dzarlax commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Both taken care of in 90b9c65b, one as a fix and one as a documented rule.

Period preference between tests — fixed. Right, and it was my own test's leak: window.localStorage.clear() does not touch the in-memory store next to it, so the first case's "month" became the default every later case inherited when the URL named no period. The preference is reset in beforeEach now.

Tracking every synchronized person — not taken. Keeping only the latest person is the intended rule, and a Set would trade this scenario for a worse one.

Arriving at a person's team is a navigation, not a remount. With a set: a reader opens A's team, re-scopes to something else from the topbar, looks at B's team, then clicks back to A's team — and gets B's roster, because A was already claimed. The address says A and the screen shows B. That disagreement is the thing this whole migration set out to remove.

The guard's job is narrower than "sync each person once ever": absorb re-renders and remounts of the same person, so a route sync never fights a scope the reader picked. A → B → A re-claiming is that rule working.

Where you are right is that nothing said so and nothing tested it, which is why it reads as an oversight. src/frontend/src/lib/portal/route-scope-sync.ts now spells out the reasoning, and there are four cases pinning it — including A → B → A explicitly, so anyone who later reaches for a Set sees the intent first.

On the docstring coverage check: added docstrings where a line of prose carries information — what an absent URL param means, the scope picker's shapes, why a trend point omits a series key instead of zeroing it, the guard and store contracts. That takes the diff from 52% to 62%. I stopped there rather than reaching 80%: the remaining gap is mostly API-client types that mirror the server DTO field-for-field (PeriodView, TimeseriesView, MetricBucket) and upstream code this branch only touched. A docstring restating a symbol's own name is noise that ages into a lie, so I would rather leave the number short than pad it.

pnpm typecheck clean, eslint --max-warnings 0 clean, 814 unit tests pass in the ported tree (827 in the source branch, which includes the Storybook browser suites).

@dzarlax
dzarlax enabled auto-merge August 5, 2026 15:43
@dzarlax
dzarlax added this pull request to the merge queue Aug 5, 2026
Merged via the queue into constructorfabric:main with commit 6e354f3 Aug 5, 2026
49 checks passed
@dzarlax
dzarlax deleted the feat/portal-shell branch August 5, 2026 16:25
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.

4 participants