Skip to content
Ritesh FirodiyaGet in touch

Work / Chitragupt / Wiki / Decisions

2026-09-07-portfolio-observation-panels-belong-in-aside

Decisioncanonicalverified 2026-09-07

DECISION.2026-09-07.PORTFOLIO-OBSERVATION-PANELS-BELONG-IN-ASIDE

Portfolio observation panels live in the sticky aside, and rank by rupees

Decision

The three observation panels — "What lost you money", "What made you money", "Gaps in your safety net" — render stacked vertically inside the sticky right aside, under "Goals · funding pace", on both /portfolio and /portfolio/holdings. They are not a full-width three-column grid in the main column.

Separately, they rank by rupees gained or lost, not by percentage return.

Why

Placement. PILLAR.PORTFOLIO.REVIEW.BLOCK-ORDER on pillar-portfolio listed "(4) Observation panels · (5) Goals · funding pace aside" without naming a column for (4), and the implementation read that as "(4) goes in main". Every other source disagreed:

  • portfolio-review.html puts the range switcher, Goals, and all three panels inside one <aside class="lg:sticky lg:top-20 lg:self-start space-y-3">.
  • portfolio-holdings.html does the same.
  • PILLAR.PORTFOLIO.PATTERN-PANELS on the same entity page already described them as "three narrative-list asides".
  • /portfolio/holdings was already rendering them in the aside, using a [&>section]:!grid-cols-1 CSS override to force the three-column grid back into one column — a workaround that only exists because the component assumed the wrong container.

So one line of one entity page said main; the wireframes, the neighbouring line of the same entity page, and one of the two real call sites all said aside. The entity line is corrected rather than the four sources that agreed.

This also fixed two user-visible complaints at once. The right column held only Goals and an upload button — "the right panel is useless" — because three of its four intended blocks were somewhere else. And side by side in a grid, a 6-row winners panel next to a 2-row safety-net panel left a visible hole; stacked, they simply run on.

Ranking. The panels ask what made or lost the user money, and were sorted by percentage. On a real 111-holding account that put RELINFRA (−79% of ₹5,816 = −₹22k) above RVNL (−39.9% of ₹83k = −₹33k), so the positions that actually cost the most money never appeared. PortfolioTopHolding gained an unrealised_paise field and both lists now sort on it. Rows lead with the rupee figure, matching the wireframe's own phrasing ("Net −₹10.7L across two brokers").

Impact

  • pillar-portfolio — REVIEW.BLOCK-ORDER rewritten to name the column each block belongs to; PATTERN-PANELS notes the rupee ranking.
  • PortfolioPage.tsx — PortfolioShell takes an observations slot rendered in the aside; ObservationPanels stacks (space-y-3) instead of gridding.
  • portfolio/holdings/page.tsx — the [&>section]:!grid-cols-1 override deleted, no longer needed.
  • portfolio-review.ts / portfolio-review-engine.ts — unrealised_paise per holding; top_winners / top_losers ranked by it.

The same bug, found at three call sites

unrealised = nav − invested computed on the whole portfolio counts EPF/PPF/NPS balances — which report a value against a cost basis of zero — as pure gain. It read ₹14.16L on an account whose real unrealised gain was ₹13.3k. Fixed in:

  1. portfolio-review-engine.ts — the stored unrealised_paise.
  2. InvestedKpiCard — recomputed locally, ignoring the engine's now-correct field.
  3. portfolio-holdings-rows.ts — the Holdings totals strip, which computed ytd correctly from the cost-basis-scoped pair and unrealised from the unscoped one, two lines apart.

A fourth instance sat in PortfolioVsNiftyChart's callout, which claimed the portfolio beat Nifty by ₹14.16L for the same reason. All four now use the cost-basis-scoped pair, which is what nav_with_cost_basis_paise exists for.

Second pass — the rest of the variant audit

Read all 9 wireframes under .context/designs/web/portfolio/. Three more divergences, all resolved toward the design:

  • "Capital gains booked · per AY" was three KPI tiles, not a chart. The design draws a per-AY timeline: one bar per assessment year, value above, AY and tax below, a baseline dot, and a dashed "₹0 · so far" outline for the year in progress. PILLAR.PORTFOLIO.CHART.CG-BOOKED-PER-AY on pillar-portfolio already said "per-AY LTCG + STCG timeline" — the code had drifted, the entity had not. Built as CapitalGainsTimeline; the current AY's figures moved to a one-line summary beneath it. getPortfolioHistorySeries gained realised_tax_paise and ltcg_exemption_unused_paise to feed the per-bar sub-labels.
  • The Top Holdings card is in no wireframe. Deleted, along with CgStat, DONUT_PALETTE and the readOnly prop that existed only for it. BLOCK-ORDER item (6) is a holdings drill link, which the allocation card's "111 holdings · See all →" already provides. The holdings TABLE on /portfolio/holdings is a different thing and is untouched.
  • The Goals empty state described an action and offered no way to take it. Added a "Set a goal" button, gated to non-ca_client lenses like the populated card's "Map holdings" link.

Alignment fixes: the status banner is emerald (border-emerald-500/30 bg-emerald-500/5 text-[11.5px]) per the design, not zinc; its trailing copy now reads "see right column" rather than "see below", which stopped being true when the panels moved; the chart title carries the resolved range inline ("· FY 2021-22 → FY 2026-27").

Kept against the design, deliberately: the four-KPI strip (NAV / YTD / Invested / Realised CG) appears in no wireframe, and the design instead surfaces NAV in the donut centre. It is left in place because it is the one part of the page the product owner singled out as working. Flagged rather than deleted.

Still divergent: the design puts the range switcher in the aside as #pf-period-switcher with 1M · 3M · 6M · 1Y · Custom; ours sits in the chart header with 1Y · 3Y · 5Y · MAX. PILLAR.PORTFOLIO.RANGE-SWITCHER explicitly records the implementation's set as intentional ("chart-level; JS supports 1Y · 3Y · 5Y · MAX"), so this is left alone as a recorded divergence rather than silently changed.

Third pass — surplus-component sweep across all variants

Compared every wireframe's component inventory against the implementation and deleted what the designs don't have. Removed:

  • The four KPI cards (NAV / YTD return / Invested / Realised CG) on the review page, plus their seven supporting components. In no wireframe — the design surfaces NAV in the donut centre instead.
  • The Holdings totals strip (Showing / NAV / YTD / Unrealised / Unmapped). portfolio-holdings.html has no tile strip; "Showing 9 of 42" is pagination text and "YTD" is a table column header.
  • "Upload broker statement" from the aside. Every wireframe's aside ends Goals → panels → footnote.
  • The full-width SEBI strip. The disclaimer itself is mandatory and verbatim per 2026-06-26-portfolio-sebi-strip, so it is RELOCATED, never dropped: every wireframe closes its <aside> with the same sentence as a centred text-[10px] footnote prefixed by "Statements land in Inbox". Now PortfolioAsideFootnote, on both the review and holdings pages. A placement and weight change, not a content change.

Font: ObservationPanel's list had no size set and inherited the page base (~16px); the design specifies text-[11.5px].

Placement corrections:

  • PerMemberAssetClassTable was a standalone full-width section. portfolio-family.html nests it INSIDE the household-NAV card under a border-t divider with a 9px eyebrow and a 10px table — the same shape "vs balanced ideal" has under the allocation donut. Re-nested and stripped of its own card border, which would otherwise have produced a card inside a card.
  • View-only members had no exit. portfolio-view-only.html ends its banner with "Open your Personal portfolio"; the implementation had no such link, leaving a member stuck in the household lens.

Checked and confirmed already matching: the Holdings filter bar (search + 3 selects + sort) is exactly the design's; the Pro Family "Upgrade · ₹550" in portfolio-family-locked.html is the top-up from a paid plan, not a price drift — the canonical TIER.PRO-FAMILY.PRICE.AY2026 (₹799) is what the code renders via PRO_FAMILY_PRICE_LABEL.

Status

Active.

Sources

Every project of mine is written down like this.

Read the résumé