Matrix Screen Audit
astromatrix-flutterbranch new-ui11 Sep 2026

Matrix Screen Audit

A full read of MatrixScreen — what it does, who calls it, and eight findings from tracing its code against the support and analytics record. Four are filed; four are corrections to claims already in the docs.

68.8%Reach, all five routes rolled up — most-reached screen after Home
29.1%True one-visit rate — second stickiest, behind component
8,479Lines across the Matrix view, service, model and widget files
1 of 49UI complaints that name conceptual complexity

Reach and return figures from the 2026-09-11 fresh production scan (102,600 tracked accounts), the first pull after the ISSUE-77 instrumentation fix released on both platforms.

What the screen is

One screen with two personalities, selected by MatrixType. It takes a chart of 29 bodies and several hundred aspects and reduces them to four or five scored words that need no astrology knowledge. It opens on the Summary tab by default, so the reduction is the first thing anyone sees.

 SelfRelationship
TabsSummary · Purpose · Harmony · Love · Abundance · EvolutionSummary · Chemistry · Support · Connection · Evolution
Facets15 (3 per tab)12 (3 per tab)
LensesAll Aspects · Foundation (Birth) · All Transits · Weekly (Transits) · Monthly (Transits) · Growth (Progressed) · Soul (Draconic)All Aspects · Connection (Synastry) · Unity (Composite)
Routes/matrix /facets /facets-weekly /facets-monthly/relationshipfacets

Data pipeline

calculateHoroscopes(type) cache key includes user1.id + user2.id + transit datetime → _transformData() enrich to MatrixItem (~60 fields: planet, house, aspect, element, modality, ruler, orb, shadow periods) → filterItems() facet + lens + "hide transits ended before yesterday" + drop 1970-epoch rows; memoized on an Expando, day-stamped → classifyItemPolarity() positive / negative / neutral → MatrixAttribute per-facet {positive, negative, total} → radar + gauges

Why it is not one screen

Its parts render well outside its own routes, which is why every prior ranking understated it:

With Home at 85.9% reach and a Matrix panel inside it, the engine touches effectively the whole tracked fleet. Work here propagates to six surfaces at once.

Filed this session

ISSUE-131OpenMedium

/matrix-report is a route that exists nowhere

When a full Matrix report generates but fails to save to the journal — a case the code explicitly labels "Network error" — the user gets a seven-second snackbar whose action is "View Now", navigating to /matrix-report (matrix.dart:3005).

That route is not registered in HybridRouter; _findMatchedPrefix matches only path == key or path.startsWith('$key/'), so /matrix does not cover it; there is no intelligent-route mapping, so navigate() falls through to the WebView and pushes the legacy site. Grepped the legacy .NET repo, the Node repo, and this repo's web/, android/ and ios/ trees: zero occurrences outside the two Flutter lines.

Half of it was built — navigation.dart:59,79 already map /matrix-report to bottom-tab index 1. And the payload it would render is dead too: MatrixReportService.lastReportCache is assigned at matrix.dart:2939 and never read anywhere.

Why it mattersIt fires at the exact moment the user has already lost the report, and the one button offered to recover it does nothing. Second confirmed dead-route instance after ISSUE-76's /premium — worth sweeping every AppNavigation.go target in the same pass.
ISSUE-132OpenMedium

Relationship user-selection: silent pairing, and two guards that skip their own return

All three sit in _initializeMatrix()'s relationship branch, matrix.dart:446-512.

(a) Two-profile accounts get paired without being asked. With exactly two profiles and no user2 set, the screen picks the non-user1 profile and calls setUser2(otherUser) — persist defaults to true, so it writes chart_service_user2_id. That pairing then drives synastry and composite charts and share links everywhere else in the app. Skipping a pointless picker is the same reasoning that made ISSUE-83 correct; the difference is that ISSUE-83 auto-selects you, and this auto-selects another person as your partner.

(b) Both bail-outs read if (<profile missing> && mounted) (:460, :506). If the widget was disposed while the picker was open the condition is false — so neither the pop nor the return runs, and initialization continues past a guard that exists precisely because the profile is missing. The mounted check belongs on the pop alone.

(c) Neither pop checks Navigator.canPop(). Tab navigation uses pushReplacement, so where Matrix is the bottom of the stack — deep link, notification, or restored startup tab route — backing out of the picker pops the last route. ISSUE-48's family. Not verified on a device.

ISSUE-136Needs a decisionMedium

The relationship report gate does not match any limit on the path it gates

showFullReport (matrix.dart:1293) hides the Report button for Summary + relationship + all aspects — which is relationship's default filter (matrix_service.dart:67) on tab 0. So the Relationship Matrix's default state has no Report button and no explanation. relationshipfacets already carries the app's worst bounce: 49% under ten seconds, 93.2% for share-link arrivals.

It is deliberate. Commit 40ae95f1 removed two hard throws ("disabled to prevent oversized payloads") and narrowed the button condition from (relationship || all aspects) to (relationship && all aspects) — re-enabling self+all-aspects and relationship+Connection/Unity, keeping off every-facet × synastry × composite.

But the stated reason does not survive tracing.

PathEndpointLimit
Per-facet AI reportai/generateDataReport → ai.js100 kb — bodyParser.json() with no limit set
Full report — the gated onejournal save → app.js /journals50 mb
Storage ceiling on that pathMongo componentData.report16 mb (BSON hard limit)

The gated button never calls the AI — _generateFullMatrixReport builds HTML locally and saves it. So the 100 kb ceiling, which is real and is exactly why MatrixItem.toAiReportJson() exists as a slimmed payload, is not what gates this button.

Ask the original devWas relationship + All Aspects ever measured, or inferred from a failure? What was the real payload size and error text? If the failure was ClientException: Failed to fetch, was a 100 kb AI-path failure applied to the native path by analogy?

The defensible gate is a different one. ISSUE-7c was measured: iOS peak memory roughly doubled, 280 MB → 500 MB, with Android OOMs on the same path, and this report is the largest input that path can take. Its structural fix was explicitly deferred — generatePdfFromHtml accumulates one pw.Widget/TextSpan tree for the entire document before layout, and PdfPreviewScreen rasterises every page and keeps them resident. If a gate stays, it should be documented as a render-memory gate on the PDF path, not a payload gate on the button.

Separately, the silence is its own defect: the old code told the user to pick a specific tab. Now the button is simply absent. And when the save does fail, the recovery offered is ISSUE-131's dead route — the same incident from the other end.

ISSUE-137OpenMedium

The screen computes relevance and polarity, then throws both away

Two cases of dead wiring on the app's highest-reach surface. Neither needs a product decision, and both propagate to Home and Moon for free.

(a) Nothing uses "is this happening right now"

  • MatrixItem.isActive — wrapping AstroHelper.isHappeningNow(begins, ends) — is defined on the model and never called anywhere in the Matrix.
  • percentComplete is on every item, read only when constructing Profection and TimeLord items (matrix.dart:2687, 2724). Never for display or ordering.
  • TransitActiveBadge is a shipped, working widget used by oob_widget.dart and calendar_event_dates.dart — not by the Matrix.

Ordering is instead _categoryGroupOrder: houses, then aspects, then the rest. Static, and identical every visit.

Why this is the cheapest fix on the listThe screen's measured weakness is that it does not change between visits — the default lens merges birth with every transit type, so there is no "today". Sorting active-first, then by percentComplete nearest peak, makes it differ daily, because percentComplete moves on its own. No default change, no new data, no product decision.

(b) The polarity numbers are passed in and discarded

MatrixCategoryPanel declares attributeTitle (the Success/Obstacles word pair) and attribute (positive/negative/total counts) as constructor fields at matrix_category_panel.dart:27-28, 53-54 and never reads either. MatrixScreen computes them per facet via calculateFilteredAttributes and passes them in; _CategoryHeader renders only the facet title, a help icon, the AI-report icon and the view toggle.

So the screen already knows "Intimacy & Romance — Passion 8 · Distance 2" and shows only the facet name. Rendering it is wiring up two dead parameters, and it partly answers the separate complaint that the gauge bars have no absolute reference. Both strings are already translated through getLang, so it adds no English-only surface.

Corrections to claims already in the docs

ISSUE-48Corrected

The null-guard cited as proof lives on the wrong branch

ISSUE-48's pre-ship verification note states that MatrixScreen has an explicit if (user1 == null) branch, and uses it as evidence that the Matrix tab cannot null-crash in a logged-out state. The branch exists, but sits inside if (matrixType == MatrixType.relationship). The self Matrix — /matrix, the actual bottom-tab destination — has no user1 guard at all.

The conclusion still holds, by a different mechanism: calculateHoroscopes returns 'No user selected' (horoscope_service.dart:1485) before any force-unwrap, so the screen shows an error snackbar over an empty list rather than crashing. Worth correcting so nobody re-derives the guarantee from a guard that is not on that path.

ISSUE-7cCorrected

"Intentionally-supported, unguarded case" is half true

ISSUE-7c describes large relationship reports as now unguarded following the payload-guard removal. At the engine level that is right — but the button for exactly that combination is still hidden, and the condition was deliberately narrowed rather than removed in the same commit. Two separate mechanisms; the UI one is still in force. Detail in ISSUE-136 above.

UX doc §3Corrected

The Home "Transits" labelling fix is not supported by its own evidence

The UX doc concludes that weekly and monthly transits are hard to find because "nothing on the home grid says Transits", and recommends a labelling fix. But the two reporters asked:

"where is the weekly and monthly forecasts"

and the buttons are labelled exactly "Weekly" and "Monthly". Someone using those words and still not finding them was not blocked by the label.

The labels also cannot grow: home_buttons.dart:158-159 sets maxLines: 1 with TextOverflow.ellipsis in a card 110 px wide on compact phones, so "Monthly Transits" truncates — and it is getLang(button.label), so translations degrade worse than English suggests.

Better candidate, unverifiedHomeButtons is the third block on Home, after BigThreeRow and seven HomeHeaderCards (home_screen.dart:504-529). On a phone it is plausibly below the fold. Check that on a device before spending anything on naming.
This auditCorrected

Two of my own earlier claims, withdrawn

  • "Audit the labels — does Weekly/Monthly say transits?" The Matrix lens menu already reads "Weekly (Transits)" and "Monthly (Transits)" — plain word first, technical term in parentheses. The competitive-position doc already identifies this as in-house prior art to copy. The naming gap is on Home, not here.
  • "Group the default view by type." Grouping by type already exists as a pattern — Home sections by forecast type, and _categoryGroupOrder orders by houses and aspects. Superseded by ISSUE-137(a), which is the part that genuinely is not built.

What is verified, and what is not

ClaimStanding
/matrix-report unregistered and absent from all four codebasesVerified — registration block, prefix matcher, and greps across Flutter, legacy .NET, Node, web/native assets
100 kb on ai.js, 50 mb on app.js, /journals mounted on app.jsVerified in source
Gate history: two throws removed, condition narrowed in 40ae95f1Verified by diff
isActive, percentComplete, TransitActiveBadge, attributeTitle, attribute unused in the MatrixVerified by grep across lib/
A relationship all-aspects report is roughly 1 MBEstimate only — ~200–350 items × ~1–3 kb HTML. Settle by logging reportHtml.length once
HomeButtons sits below the foldUnverified — inferred from block order, needs a device
ISSUE-132(c) pops the last routeUnverified — conditions confirmed in code, resulting screen state not observed

Open decisions

The default lens. The default is all aspects — birth plus every transit type merged, and not a stored choice: saveCategory deletes the key for full-summary, so it is the absence of a decision. Note this affects fewer people than it appears: the Home buttons already route to specific lenses (?category=all birth, weekly, monthly), so the default only governs people who tap the Matrix bottom tab. There is no AB framework, no session-level events and no app-version tag on stats writes, so changing it is a swap you cannot grade. ISSUE-137(a) is the change that gives the screen a "today" without needing this decision at all.

The relationship report gate. Keep, move to the PDF path, or remove — pending the dev's answer and one length measurement.

ISSUE-7c's structural PDF fix. Deferred once already. This is the item behind the memory doubling, and it is the real constraint on large reports.

Draconic is silently unavailable to some users. _matchesSelfChartTypeFilter returns false for draconic when birthTime is empty. Those users can never see that lens and are never told why. Worth fixing regardless of what is decided above.