## Demo
<img width="2624" height="1636" alt="image" src="https://github.com/user-attachments/assets/6a1b0cfd-e86f-453b-b5a0-4129e21ffe92" />
<img width="2624" height="1636" alt="image" src="https://github.com/user-attachments/assets/2de7c447-9be1-4934-87e1-14dde6119292" />
<img width="2624" height="1636" alt="image" src="https://github.com/user-attachments/assets/4816f456-b22a-40e8-a7f6-350d4ee1e520" />
<img width="2624" height="1636" alt="image" src="https://github.com/user-attachments/assets/9cfec359-aa25-4201-b15e-c21c7765bdbe" />
Proof that the toggle now restates both Current and Past ARR date-aware (not the full −$34M from Current only), shown by importing and calling the real changed code directly — no HTTP, no DB.
Backend — getTwitterImpactSummary is now subscription-level (klair-udm)
Ran node /tmp/demo-klair2890-backend.mjs (imports the real buildSqlQuery + transformQueryResults):
=== Generated SQL (buildSqlQuery('getTwitterImpactSummary')) ===WITH arr AS (
SELECT subsriptionid, MAX(subscriptionenddate) AS end_date
FROM core_finance.arr_detail_final
GROUP BY subsriptionid
)
SELECT i.impact, a.end_date AS subscription_end_date
FROM core_finance.arr_gap_twitter_impact i
LEFT JOIN arr a ON i.subsriptionid = a.subsriptionid
=== transformQueryResults() on a synthetic Redshift Data API payload ===
rows in -> subscriptions out : 4 -> 4 (1:1, no fan-out)
totalImpact === SUM(impact) : -185 === -185 -> true
null end date passed through : {"impact":-5,"subscriptionEndDate":null} (not coerced)
doubleValue fallback parsed : {"impact":-30,"subscriptionEndDate":"2026-04-15"}
empty records guard : {"totalImpact":0,"subscriptions":[]}
MAX(subscriptionenddate) is aggregated per subscription in the CTE before the join, so detail-row fan-out can't double-count impact; LEFT JOIN keeps null-date rows; column order (impact, subscription_end_date) matches the transformer's row[0]/row[1].
Frontend — date-aware Current + Past restatement (klair-client)
Ran node /tmp/demo-klair2890-helper.mjs (imports the real applyKhorosTwitterImpact, transpiled verbatim) on a synthetic book whose end dates straddle each baseline, at report date 2026-05-31:
Full Twitter book (totalImpact) = -$230 | raw currentArr=$1,000 pastArr=$900type | pastBaselineCut | currentAdj | pastAdj | adjCurrent | adjPast | variance
MTD | 2026-04-30 | -$100 | -$150 | $900 | $750 | $150
QTD | 2026-03-31 | -$100 | -$180 | $900 | $720 | $180
YTD | 2025-12-31 | -$100 | -$200 | $900 | $700 | $200
TTM | 2025-05-31 | -$100 | -$215 | $900 | $685 | $215
OFF (includeTwitter=false): {"currentArr":1000,"pastArr":900,"variance":100,"currentAdjustment":0,"pastAdjustment":0}
-> byte-identical to raw -> true
strict-> boundary: sub endDate == report date -> currentAdjustment = 0 (excluded)
currentAdjustment is identical across all snowball types ("current is current", cut at the report date); pastAdjustment grows by horizon (MTD < QTD < YTD < TTM), each the date-filtered sum at its own baseline — never the full −$230 book. This is exactly the live-data shape from the acceptance table (Current −$5.84M ≪ TTM −$33.67M ≈ book −$34.09M).
UI — see it on the three surfaces (ARR & Retention Reports → Maintenance Summary)
1. Open the Maintenance Summary view; locate the "Include Twitter" page-level toggle.
2. Toggle it ON and watch the Khoros row/figures: both Current ARR and Past ARR drop (previously only Current moved), and DM / Variance recompute from the two adjusted figures.
3. Switch the snowball horizon MTD → QTD → YTD → TTM: the Current adjustment stays the same while the Past adjustment grows with the horizon.
4. Check the Key Metrics DM card (TTM) and the Acquisitions tab (TTM) — both now use the adjusted past as the DM denominator / variance baseline.
5. Toggle OFF → every Khoros figure returns to its raw value (exact no-op); retention columns (Gross/Net Retention, Churn, Up/Downsell, New Business) and non-Khoros classes never change.
> _Screenshot: Maintenance Summary with "Include Twitter" ON, showing Khoros Current and Past both adjusted — _<!-- paste screenshot here -->
Most at risk from this change — the #3044 OFF no-op, the strict->/baseline-date math, the no-fan-out backend shape, and the three caller surfaces. Verified via the scoped tests for exactly those (all encode the live golden numbers, the boundary, and byte-identical no-op):
frontend (vitest) — applyKhorosTwitterImpact 22 + acquisitionsAdjustment 6 + useMaintenanceData 5 + KeyMetricsSummary 2 = 35 passedbackend (node) — klair-udm/tests/redshift (queryBuilder + responseTransformer) = 78 passed
---
## fix(maint-report): Twitter toggle adjusts both current AND past ARR, date-aware
Linear ticket: KLAIR-2890
### Feature overview
The "Include Twitter" toggle on the ARR & Retention Reports Maintenance Summary restates Khoros figures to reflect the projected X/Twitter churn impact. The KLAIR-2884 implementation (PR #3044) had two bugs:
1. It subtracted the entire projected loss (≈ −$34M, the full Twitter book) from Current ARR only.
2. It left Past ARR untouched.
Both are wrong. The full-book subtraction is not date-aware: it counts subscriptions that have already expired as of the report date. And leaving Past unadjusted means Variance and DM mix an un-Twittered past with a Twittered current.
This PR makes the impact date-aware and applies it to both Current and Past ARR. For any ARR figure at reference date D, we subtract only the impact of subscriptions still alive at D:
adjustment(D) = SUM(impact WHERE subscriptionEndDate > D) // strict >, impact is negative
- Current ARR uses D = report date (e.g. 2026-05-31), identical across all snowball types — "current is current" — so it subtracts only the remaining un-expired Twitter ARR (≈ −$5.84M at the live data point, not the full −$34M).
- Past ARR uses D = the per-snowball-type past baseline date (MTD → prior month-end, QTD → prior quarter-end, YTD → prior calendar year-end, TTM → −12 months), so Variance = adjustedCurrent − adjustedPast and DM = adjustedCurrent / adjustedPast exclude Twitter consistently in both numerator and denominator.
### Specs (both shipped in this PR)
- [03-twitter-impact-subscription-level-query](features/maint-report/arr-retention-twitter-toggle/specs/03-twitter-impact-subscription-level-query/spec.md) — backend (klair-udm): make getTwitterImpactSummary date-aware by returning per-subscription rows tagged with end dates instead of a single scalar.
- [04-date-aware-current-and-past-adjustment](features/maint-report/arr-retention-twitter-toggle/specs/04-date-aware-current-and-past-adjustment/spec.md) — frontend (klair-client): apply the date-aware restatement to BOTH current and past ARR across all three surfaces.
### Implementation summary
Backend data shape (klair-udm). buildTwitterImpactSummaryQuery() now JOINs core_finance.arr_gap_twitter_impact → core_finance.arr_detail_final on the (misspelled-on-both-tables) subsriptionid key. The subscription end date is aggregated as MAX(subscriptionenddate) per subscription in an arr CTE before the join, so detail-row fan-out cannot double-count impact. A LEFT JOIN preserves impact rows with no detail row / a null date. The transformer returns subscriptions[] of { impact, subscriptionEndDate } (ISO YYYY-MM-DD | null) and retains the scalar totalImpact = SUM(impact) for the OFF no-op guard. Wired through the standard five-file AppSync pattern (queryBuilder.mjs, responseTransformer.mjs, validators.mjs, app.mjs, gql/schema.graphql). The query stays argument-free; the 136-row book is filtered client-side.
Frontend date-aware application (klair-client). The pure helper applyKhorosTwitterImpact.ts is rebuilt to take the per-subscription rows + a current reference date + the snowball type, derive the per-type past baseline date via UTC date arithmetic (Date.UTC, no day-drift), filter with a strict-> lexicographic ISO comparison (null end dates excluded from both adjustments), and return adjusted current + adjusted past + variance = adjustedCurrent − adjustedPast + both adjustment deltas. useKhorosTwitterImpact.ts is widened to expose subscriptions[]. All three surfaces are wired and prop-threaded: Key Metrics DM (keyMetricsAggregate.ts / KeyMetricsSummary.tsx), Maintenance Summary by-BU (useMaintenanceData.ts), and the Acquisitions tab (acquisitionsAdjustment.ts). ~21 files changed.
### Golden-number acceptance (report date 2026-05-31, live 136-subscription book)
| Reference | Cut (endDate >) | Adjustment |
|-----------|-------------------|-----------:|
| Current (shared, all snowball types) | 2026-05-31 | −$5,840,045 |
| Past MTD | 2026-04-30 | −$6,666,222 |
| Past QTD | 2026-03-31 | −$10,500,447 |
| Past YTD | 2025-12-31 | −$19,795,377 |
| Past TTM | 2025-05-31 | −$33,667,003 |
Full book (totalImpact) = −$34,087,065. Past is the date-filtered sum per snowball type — it is never the full book magnitude.
### Test coverage
- Backend (klair-udm node): 78 pass (+5 new). Column-order contract, CTE-before-join no-fan-out, null/empty coalescing, and the totalImpact === SUM(impact) invariant.
- Frontend (vitest): 35 pass (+16 new). Golden numbers at report date 2026-05-31 (Current and all four past baselines above), per-type baseline-date derivation incl. mid-month / quarter / leap-year edges, strict-> boundary (endDate == D excluded), null-date exclusion, OFF / zero-impact / empty-subscriptions byte-identity, timezone safety under TZ=UTC+14 and UTC-11, and YYYY_MM_DD → YYYY-MM-DD underscore normalization.
### Self-review
No CRITICAL or IMPORTANT issues. One MINOR left as-is: SnowballType type-aliases the existing ARRSnowballType — defensible to keep the pure helper self-contained, zero behavior impact. Verified: date derivation correct across all edge cases (Jan / quarter / year / leap boundaries, Date.UTC no day-drift); the no-op is truly byte-identical including the DM denominator (no Infinity/NaN); the SQL has no fan-out and no injection surface (static query); the 'Acquisition' snowballType reaching useMaintenanceData is harmless (its output isn't rendered on the Acquisitions tab).
### Guarantees preserved
- OFF / totalImpact === 0 / empty subscriptions is an EXACT byte-for-byte no-op — Current, Past, variance, DM, sort order, and rollups all match the unadjusted path (no date math executed, DM denominator unchanged).
- Retention columns and non-Khoros classes are untouched. Gross/Net Retention, Churn, Downsell, Upsell, and New Business read raw backend values; only Khoros records are adjusted.
- Null end dates are excluded from both current and past adjustments (defensive "not alive" guard).
🤖 Generated with [Claude Code](https://claude.com/claude-code)