# NAN-475 — Implementation Plan

Branch: `feat/nan-475-eps-chart-period-legend` · Window: `nanodev:4`

## Issue 1 — Period filter drift

**Root cause:** `mergeQuarterlyEps` (`src/features/stock-detail/presentation/hooks/useMergedEpsData.ts:52-74`) treats every `epsHistoryItem` as "past" regardless of whether `actualEps` is actually populated. If backend returns `actualEps == 0` (or even `null`) for an unreleased quarter that is in the history array, that row counts toward the 4 past slots and the actual/forecast split drifts visually — a future-only quarter renders a solid actual dot at zero.

The dot renderer in `EpsChart.tsx:82-95` only suppresses each dot on strict `== null`. So `actualEps === 0` paints a solid dot at $0, which is the visible bug.

**Fix (Philip's diagnosis):**
1. Combine history + forecasts, de-dupe by `period` (prefer history for any duplicate).
2. Partition by predicate `actualEps != null && actualEps !== 0`:
   - `past` bucket = items satisfying predicate
   - `future` bucket = everything else
3. Sort each bucket chronologically.
4. Output = `past.slice(-4)` concatenated with `future.slice(0, N)`.
5. As a defensive side effect, normalise `actualEps === 0` → `null` on items that end up in the `future` bucket so the chart's `actualEps == null` check stops painting the zero dot.

**Open Q for greenlight — value of N:**
- Current `MAX_CHART_ITEMS = 4` and existing code prefers past, fills remainder with future, giving 4 total.
- Figma 6320:108409 shows exactly 4 ticks on the x-axis (2025 Q1–Q4, all with both actual+estimate dots). No future-only column in the design.
- TSLA today (2026-05-19) per Philip's example should render "2025 Q2/Q3/Q4 + 2026 Q1" as past → 4 past, 0 future.

Two interpretations of "next N forecasts strictly":
- **(A) Total = 4, past-priority (matches Figma):** N = `4 - past.length` capped to 4 past items. Equivalent to today's behaviour but with the corrected predicate. Chart width unchanged.
- **(B) 4 past + N future fixed (more informative, breaks Figma tick count):** e.g. 4 past + 2 future = 6 columns. Chart needs more horizontal room; x-axis may overflow at 361 px.

**Default recommendation: (A).** Stays within Figma layout, fixes the drift, single-line change to predicate. Confirm via Telegram before patching.

**Verification plan:**
- Unit test: feed `mergeQuarterlyEps` a synthetic history with one trailing `actualEps: 0` row; assert that row lands in the `future` bucket and is excluded from `past.slice(-4)`.
- Unit test: history with 6 fully-released quarters + 2 estimate-only forecasts → returns last 4 released.
- Manual EAS check on three tickers: TSLA, AAPL, MSFT (or NVDA). Confirm the rightmost solid dot is the most recent reported quarter; everything to the right of it renders dashed-only.

## Issue 2 — Forecast legend dashed circle parity

**Root cause:** Legend uses RN `View` with `borderStyle: 'dashed'` (`EpsSection.tsx:251-257`), which gives platform-inconsistent dash count and stroke rendering. In-chart forecast dot uses Skia `Circle` with `DashPathEffect intervals={[3, 2]}` (`EpsChart.tsx:85-87`). Stroke widths also differ (RN `borderWidth: 2` vs Skia `strokeWidth: 1.5`).

**Figma spec verbatim (6315:441534 chart dot, 6315:441546 legend dot — identical):**
- size: `12 × 12`
- border: `2px` dashed
- color: `#81F4E1` (brand[300])
- border-radius: `100px` (full circle)
- Both nodes use the same Tailwind class `border-2 border-[#81f4e1] border-dashed`, i.e. they are designed to be visually identical at the same size.

**Spec deltas to current impl:**
| Property | Figma | Chart now | Legend now |
| --- | --- | --- | --- |
| size (bb) | 12 | 12 (r=6) | 12 |
| stroke | 2 | 1.5 | 2 (RN border) |
| color | brand[300] | brand[300] ✓ | brand[300] ✓ |
| dash count | 8 (per user spec) | derived from `[3,2]` → ≈7–8 at r=6 | platform-default (uncontrolled) |

**Fix:** unify both legend and chart forecast dots through a single Skia-rendered component.
1. New component `ForecastDot` in `src/features/stock-detail/presentation/components/analyst-forecasts/ForecastDot.tsx` — renders a Skia `Canvas` containing a single `Circle`:
   - `r` chosen so the visible outer edge sits inside a 12 × 12 box with stroke centered on the path: `r = (size - strokeWidth) / 2 = (12 - 2) / 2 = 5`.
   - `style="stroke"`, `strokeWidth={2}`, `color={brand[300]}`.
   - `DashPathEffect intervals={[circumference / 16, circumference / 16]}` where `circumference = 2 * Math.PI * r` — yields exactly 8 dash + 8 gap segments (dash:gap = 1:1).
   - Props: `size = 12`, `color`, `dashCount = 8`, `strokeWidth = 2`.
2. Replace `styles.legendDotOutline` View in `EpsSection.tsx` with `<ForecastDot size={12} />`.
3. In `EpsChartDots`, swap the inline `SkiaCircle + DashPathEffect` for the same `ForecastDot` math (or extract the dash-intervals helper into the shared file). Keeps `r=5`, `strokeWidth=2` instead of the current `r=6, strokeWidth=1.5`.

**Math verification (8 dashes, REVISED dash:gap ratio after user feedback):** at r=5, circumference ≈ 31.42. User-confirmed via screenshot that dashes are visibly chunkier than gaps — dash:gap is **NOT 1:1**.

Adopt **dash:gap = 2:1** (recommended default).
- Per-pair length = circumference / 8 ≈ 3.927.
- `dash = 2/3 · pair ≈ 2.618`, `gap = 1/3 · pair ≈ 1.309`.
- `DashPathEffect intervals = [2.618, 1.309]` at r=5, or generically `[2 * c / 24, c / 24]`.

If user prefers 3:1 (even chunkier dashes): `[3 * c / 32, c / 32] ≈ [2.945, 0.982]`.
If 3:2: `[3 * c / 40, 2 * c / 40] ≈ [2.356, 1.571]`.

**Open Qs:**
1. Skia `Canvas` for a 12 × 12 legend dot is heavier than a `View` border. Acceptable trade for exact parity. **Recommendation: use Skia Canvas — parity is the whole point of the ticket.**
2. Dash:gap ratio — recommend **2:1**, confirm.

**Verification plan:**
- Snapshot test for `ForecastDot` (1 dash count, default props).
- Visual EAS preview side-by-side: legend dot next to a chart forecast dot — confirm identical size, stroke, dash count, colour on both iOS and Android.

## Backend probe results (authenticated, 2026-05-19)

Probed via `mfathonin+sim1@nanostreet.ai` against `/market/stocks/<ticker>/detail/earnings` and `/detail/analyst-forecasts`.

**`/detail/earnings.eps_history`** — clean shape on both TSLA and AAPL:
- All rows have non-null, non-zero `actual_eps`.
- Rows are reverse-chronological strings like `"2026-Q2"`, `"2026-Q1"`, …
- Both TSLA and AAPL include `2026-Q2` already as released (real `actual_eps`).
- The drifting-zero-actual case Philip warned about does **not** manifest with current staging data, but the defensive predicate still lands safely — no behaviour change for clean data, immune to backend regression.

**`/detail/analyst-forecasts.eps_estimates`** — anomaly worth flagging (OUT OF SCOPE for this ticket):
- Returned with `period_type: null` (Python `None`).
- DTO schema: `period_type: z.enum(['quarterly','annual']).optional().default('quarterly')` — `null` is *not* `undefined`, so Zod parse will reject every estimate row.
- Effect: forecasts likely never reach `EpsSection` (filter `e.periodType === 'quarterly'` on an empty list returns empty). Chart shows history-only — matches what we see today.
- Also: period uses `YYYY-MM-DD` format (e.g. `2030-12-31`) rather than `YYYY-QN`, so period-key matching with history would not work even if parsing succeeded.
- **Action:** add a `// TODO(NAN-???)` comment in `epsEstimateDtoSchema` flagging null `period_type` + date-format mismatch; do NOT fix in this PR. Suggested follow-up ticket.

## File touch list / LOC estimate

| File | Change | LOC |
| --- | --- | --- |
| `src/features/stock-detail/presentation/hooks/useMergedEpsData.ts` | rewrite `mergeQuarterlyEps` partition | +20 / -10 |
| `src/features/stock-detail/presentation/components/analyst-forecasts/ForecastDot.tsx` | new component | +35 |
| `src/features/stock-detail/presentation/components/analyst-forecasts/EpsSection.tsx` | swap legend `View` → `<ForecastDot />`, drop `legendDotOutline` style | +3 / -10 |
| `src/features/stock-detail/presentation/components/analyst-forecasts/EpsChart.tsx` | use `ForecastDot` math (or constant) for in-chart dots; r=5, strokeWidth=2 | +5 / -5 |
| `useMergedEpsData.test.ts` (or new) | partition unit tests | +30 |

**Total: ≈80 LOC, well under the 350-line file ceiling, single concept per commit.**

## Commit plan

1. `fix(stock-detail): partition EPS quarterly merger by actualEps presence (NAN-475)` — useMergedEpsData + tests.
2. `fix(stock-detail): unify forecast dot Skia rendering for legend↔chart parity (NAN-475)` — ForecastDot + EpsSection + EpsChart.

Gates before push: `rtk pnpm lint --fix && rtk pnpm typecheck && rtk pnpm test --testPathPattern="stock-detail|analyst-forecasts|EpsChart|EpsSection|useMergedEpsData"`. Plain `git push` (no force).

## Waiting on

Greenlight for:
1. N = 0 (Option A) vs N > 0 (Option B) — recommend **A**.
2. Skia Canvas for legend dot vs RN `View` — recommend **Skia Canvas**.
3. Dash:gap ratio — recommend **2:1**.
4. OK to flag the `period_type: null` forecasts-don't-render bug as a separate ticket via `// TODO` comment instead of fixing here?
