# Home parity: website `/dashboard` ⇄ member app Home

**Audited 2026-09-25**, against the website on `dev-wk` and the app on `claude/phase-0`
(`/home/ubuntu/propertylab-mobile-claude`). This page was written because the app's Home was
described as matching the website when it only shared the website's DATA. Sharing a payload is not
sharing a feature, and passing app tests only prove the behaviour that was built.

**Method: three traces per control.**

1. **Website**: which Vue component draws it, and which prop or route feeds it.
2. **API**: which `member-api/v1` field or route carries the same thing, and whether it comes from
   the same PHP code.
3. **App**: which Dart file renders it, and which test proves it. The app side comes from a
   read-only audit of the Flutter code, with file:line references kept in the audit notes.

The **data** was then checked on real accounts, read-only. A tinker harness built the portal's
Inertia props and the app's `GET member/dashboard` payload for seven live accounts:

- no lead record
- non-member
- member with no units
- owners of 1, 3, 4 and 5 units
- one account with a saved, non-default layout

It diffed them field by field and made 0 write queries.

**Legend**

| Mark | Meaning |
|---|---|
| ✅ | at parity (same data, same control) |
| 🟡 | data reaches the app, but the control is missing or different |
| 🔴 | missing from the app |
| 🛠 | fixed on the server in this audit |

---

## 1. What the data check found

**Same code, same answer** on all seven accounts:

- `units` = `myProperties`
- `portfolio_kpis` = `portfolio`
- `wealth_goal` = `wealthPlan.goal`
- `layout.order` = the portal's block order

All four come from the same PHP: `OwnsPostVpUnits`, `BuildPortfolioKpis`,
`BuildWealthGoalProgress` and `DashboardBlocks`.

**Different answer: the Action Centre.** The app's builder called `BuildAttentionBand` with empty
inputs. The app therefore never received:

- a step agreed with an advisor (`journey_step`)
- a session booked with the member (`session`)
- "Join the members' WhatsApp group" or "Join the session we booked for you"
- "Ask the AI adviser your first question". The builder suppressed this row on the grounds that
  "the app has no conversation count", but it is the server that builds the list, and the server
  can count.

On the live data this showed up on all seven accounts: at least one Action Centre row that the
website had and the app did not.

🛠 **Fixed.** `ReadsMemberAgenda::memberAttention()` is now the ONE assembly both products call.

---

## 2. Control by control

### Masthead (`IdentityBand.vue`)

| Website control | API | App | Status |
|---|---|---|---|
| Greeting by hour + first name | `account.display_name` | `dashboard_screen` masthead | ✅ |
| "Your property is at {stage}" / "still being built" | `units[].current_stage`, `phase` | `_standing` | ✅ |
| "N actions need your attention" (setup excluded) | `attention[]` (`group != setup`) | `_standing` | ✅ (the count now includes the kinds that were missing) |
| Founder's quote | — (static copy) | not on app Home | 🟡 design choice, not a gap |
| **Customise** button | — | none | 🔴 see *Customise* below |

### KPI row (`KpiRow.vue`)

| Website control | API | App | Status |
|---|---|---|---|
| Portfolio value · Outstanding loans · Net equity · Monthly cash flow | `portfolio_kpis` | `home_portfolio_kpis` | ✅ |
| Tile notes (assumed loan / repaying since / across N) | `portfolio_kpis.loan_assumed`, `repaying_since`, `counted` | same | ✅ |
| **"What these four figures are made of" — See more** (per-unit breakdown, "N not valued yet") | `portfolio_kpis.breakdown` | ignored (only 7 fields parsed) | 🔴 |

### My Portfolio (`MyPropertiesSection.vue`)

| Website control | API | App | Status |
|---|---|---|---|
| One unit at a time | `units[]` | `IndexedStack` + swipe + dots | ✅ different control, same function |
| Picker **opens on the first unit waiting on the member**, marked "— needs you" | `units[].next_task` + `attention[renovation_choice]` | opens on index 0 | 🔴 |
| Cover photo and **View property** open the unit page | `units[].uuid` → `GET my-properties/{id}` | card not tappable; `onOpenUnit` never wired; no unit screen | 🔴 |
| Headline: market value / "Your figure" / what you paid | `economics.market_value`, `market_value_owner_set`, `net_price` | market value only | 🟡 no "your figure" label, no fallback to what they paid |
| **Every unit gets all five figures**, em dash when unknown (owner, 2026-09-24) | `economics.*` | figures hidden unless `market_value` and `gross_equity` are both set | 🔴 the opposite of the owner's ruling |
| **Edit your loan** (`OwnerNumbersDialog`: loan amount / tenure / rate, own value) | 🛠 `PUT my-properties/{id}/numbers` (new) | none; the warning tells the member to "open the unit", but there is no unit to open | 🔴 app |
| "Cash flow uses a loan we assumed" warning | `economics.loan_assumed` | shown | ✅ (but it points at a flow that does not exist) |
| Construction note with the developer's VP date | `phase`, `vp_at` | note without the date; `vp_at` not parsed | 🟡 |
| **Seven-stage rail** (greyed when there is no VP date) | `units[].stages[].status`, `vp_at` | not rendered; only the current stage's name | 🔴 |
| "Now: {stage} · until {date}" | `stages[].ends_on` | not parsed | 🔴 |
| **Renovation decision** in the card, highlighted | `attention[renovation_choice]` | Action Centre only | 🟡 answerable, not on the card |
| **Your next step: {task} · By {date}**, overdue in rose | `units[].next_task` | not parsed; the Action Centre row only | 🔴 on the card |
| **WhatsApp {PIC}** | `stages[].pic.phone`, `.name` | not found anywhere in `lib/` | 🔴 |
| "Nothing for you to do right now" | derived | none | 🟡 |

### Action Centre (`ActionCentre.vue`)

| Website control | API | App | Status |
|---|---|---|---|
| Rows: title · context · body · due / overdue | `attention[]` | `home_action_centre` | ✅ |
| "N overdue" chip | `attention[].overdue` | shown | ✅ |
| Renovation choice: three buttons, answered in place | `PUT my-properties/{id}/renovation` | three buttons; response ignored; whole dashboard reloaded; no disabled state while saving | 🟡 works; see *Contract notes* |
| `unit_task` → "Open this step" (unit page, renovation tab) | `attention[].url` (a WEB path) | opens the website in the browser | 🟡 should open the app's unit screen once it exists |
| `journey_step`, `session` (join link), setup rows | 🛠 now in `attention[]` | will render as generic rows. A session's `url` is its `join_url` (https) | 🟡 check how they look |
| Setup rows collapsed, **each one a link** | `attention[group=setup].url` | collapsed list of titles, **not tappable** | 🔴 |

### Wealth goal (`WealthGoalCard.vue`)

| Website control | API | App | Status |
|---|---|---|---|
| The card is a BLOCK in the member's order — moved, or hidden, from Customise | `layout.order` / `layout.hidden` key `wealth` | the Wealth Plan card is the `wealth` block (since 2026-09-25, owner: "in portal we can customise if the wealth dashboard is at top … follow website portal"): default place after the Action centre, offered in Customise as "Wealth goal", hideable; the header band holds the greeting only, as `IdentityBand` does | ✅ |
| Target, what the owned units carry, gap | `wealth_goal` | parsed, **not shown** — the card shows the plan's own goal track (`wealth_plan.key_results`) | 🟡 same block, different figures |

### Continue · Upcoming · Concierge

| Website control | API | App | Status |
|---|---|---|---|
| Continue where you left off | `learning` | `_LearningSection` | ✅ |
| Upcoming: **sessions booked with the member** (appointments + Zoom, join link) | 🛠 `sessions[]` (new) | not parsed | 🔴 app |
| Upcoming: programme sessions (locked preview for non-members) | `events[]` | `_EventsSection` | ✅ |
| Concierge: named services, request, open count | `landlord.services`, `open_concierge_requests` | `home_concierge` (opens the website) | ✅ |

### Customise (`LayoutCustomiser.vue`)

| Website control | API | App | Status |
|---|---|---|---|
| Saved order and hidden blocks applied | `layout.order`, `layout.hidden` | applied on phones; **ignored on tablet / expanded widths** | 🟡 |
| Reorder (drag / move), show / hide, save | `PUT member/dashboard/layout` (exists) | no UI, no call | 🔴 app |
| Presets ("I already own property" / "I am still planning") | 🛠 `layout.presets` (new) | none | 🔴 app |
| Reset to default | `PUT member/dashboard/layout {reset: true}` | none | 🔴 app |

### Membership, verification

| Website control | API | App | Status |
|---|---|---|---|
| Membership cards with price and benefits (members only) | `account.memberships` = names only | names on Profile, not on Home | 🟡 decide whether Home needs them. The API would need the detail. |
| **Verify your contact** overlay (email / phone code, in place) | no member-api verification routes | a fixed 403 sentence, "Try again", "Sign out" | 🔴 needs API + app. Separate piece of work: it is an auth flow. |

### The unit page (`/property/my-properties/{uuid}`, reached by *View property*)

| Website control | API | App | Status |
|---|---|---|---|
| Forecast performance tab (figures, edit value / loan) | `GET my-properties/{id}` + 🛠 `PUT …/numbers` | no screen | 🔴 |
| Renovation steps tab: VP date, repair-period end, seven stages with dates, PIC WhatsApp, checklist with assignee / due / overdue | `GET my-properties/{id}` (`stages[].tasks`, `assignees`) | no screen | 🔴 |
| **Change** a stage's start date (the stages after it move with it) | 🛠 `PUT my-properties/{id}/stages/{stage}` (new) | no screen | 🔴 |
| Who is renovating ("PropertyLab is renovating this unit for you") | `economics.renovation_by` | no screen | 🔴 |

---

## 3. User states

| State | Website | API | App | App test |
|---|---|---|---|---|
| Guest | redirected to sign in | 401 | guest Home | ✅ `guest_home_test`, golden |
| Signed in, **no lead row** | blocks render empty; setup rows | same payload (verified) | parse only | 🔴 no widget test |
| Non-member | setup rows + "Choose your membership" | same (verified) | `is_member` parsed, **never used** | 🔴 |
| Member | membership cards | names only | Profile pills | 🟡 |
| **Unverified contact** | the overlay verifies in place | 403 + message | fixed sentence, drops the server's message | 🔴 |
| Owner of one unit | card, no picker | same (verified) | card | ✅ |
| Owner of several units | picker, opens on the one needing you | same (verified) | swipe from index 0 | 🟡 no golden |
| Under construction, VP date known | five figures + "keys on {date}" + greyed rail | `vp_at` present | note without the date, no figures | 🔴 no test |
| Under construction, no VP date | the same, with "once the developer confirms" | `vp_at: null` | same as above | 🔴 no test |
| In the journey, stage live | rail + "Now: X · until D" + next step + PIC | all present | stage chip only | 🟡 |
| Loan assumed | warning, **press the figures to enter yours** | `loan_assumed` + 🛠 PUT | warning with no flow | 🔴 |
| Owner-entered loan / value | "Your figure", recomputed cash flow | `loan_owner_set`, `market_value_owner_set` | not distinguished | 🔴 no fixture |
| Renovation undecided | lit card panel + Action Centre row | `attention[renovation_choice]` | Action Centre row | 🟡 no test of the PUT |
| Next task overdue | rose line on the card + overdue chip | `next_task.is_overdue` | Action Centre only | 🟡 |
| Saved, non-default layout | applied | `layout.is_default: false` (verified) | applied on phones | ✅ 4 widget tests, no golden |
| A block hidden | not rendered | `layout.hidden` | honoured on phones | ✅ |

---

## 4. Server changes made in this audit

All are additive on `contract_version: 1`. An app build in the wild keeps working.

| Change | Why | Test |
|---|---|---|
| `attention[]` built by `ReadsMemberAgenda::memberAttention()` for both products | the two listed different to-dos for one person | `MobileDashboardTest::test_the_action_centre_is_the_portals_own_list` |
| `sessions[]` on `GET member/dashboard` | the website's Upcoming shows what is booked WITH the member; the app had no field for it | `…test_home_carries_the_sessions_and_the_customisers_presets` |
| `layout.presets` | the customiser's two starting points, from the registry | same |
| `PUT my-properties/{id}/numbers` → `SaveOwnerNumbers` (shared with the portal) | "edit your loan" existed only on the website | 4 tests in `MobileMyPropertiesTest` (set, clear, 422 message, another member's unit is a 404) |
| `PUT my-properties/{id}/stages/{stage}` → `MoveOwnerStage` (shared) | moving a stage existed only on the website | 3 tests (409 before tracking, carries later stages / keeps earlier ones, unknown stage is a 404) |
| `PUT …/renovation` answers `{ data: { property } }` and applies the contact gate | it returned a bare object while its comment promised `show()`'s envelope, and skipped the gate the reads apply | `…test_the_renovation_answer_comes_back_in_the_same_envelope_as_show`, `…test_an_unverified_member_cannot_write_either` |

The portal's own writes moved onto the same actions and gained the tests they never had:
`tests/Feature/Main/Portal/MyPropertiesWritesTest.php` (loan saved and stamped, another member's
unit is a 404, moving a stage carries the later ones, 409 before tracking). Run together with
`MobileDashboardTest`, `MobileMyPropertiesTest`, `MobileWealthPlanTest`, `PostVpTrackerTest` and the
two portal gate tests: **85 tests, all green** (2026-09-25). The live-data harness was re-run on the
new code: the Action Centre is now identical on all seven accounts.

### Contract notes for the app

- Every unit write answers with the unit in `show()`'s shape: `{ data: { contract_version, property, assignees } }`.
  **Replace the one unit you hold** rather than reloading the whole dashboard.
- `PUT …/numbers`: send only the fields that changed. An absent field is untouched; `null` means
  "go back to the platform's figure". Fields: `loan_amount`, `loan_tenure_years` (1–40),
  `loan_rate` (0–20), `market_value`. On a 422, show `errors.<field>.0` as the server wrote it.
- `PUT …/stages/{stage}` with `starts_on: YYYY-MM-DD`. It returns 409 while the team has not
  started tracking the unit (`started: false`), so hide the control in that state.
- `attention[].url` is a **website** path. Map the paths the app has screens for:
  - `/property/my-properties/{uuid}?tab=…` → the unit screen
  - `/property/ai-advisor` → Coach
  - `/membership/plans` → membership

  Open the rest in the browser. A `session` row's `url` is a Zoom join link.

---

## 5. App work, in order

Each line is done when its test exists, not when it renders.

> **Status 2026-09-25: all eight built** on the app's `claude/home-parity` branch (worktree
> `/home/ubuntu/plm-home-parity`, based on `claude/phase-0`), with widget tests and reviewed
> goldens. The unit page came from `claude/review-merge`, where it had been built on 2026-09-22 and
> never merged. **Not yet merged into `claude/phase-0`**, and Codex's `codex/ui-audit-fixes` edits
> the same Home files — whoever merges second resolves. The app's own decisions log
> (`docs/app/decisions.md`, 2026-09-25 rows) records the three choices made on the way: no live
> instalment preview (the backend owns the maths), the unit page on phase-0's design system rather
> than the 60-30-10 pass it was first built on, and a two-beat `AttentionPanel` instead of the
> portal's endless glow.

1. **Unit screen** (`GET my-properties/{id}`): figures, the seven stages with dates, PIC WhatsApp,
   and the checklist with assignee / due / overdue. Wire `onOpenUnit` from the Home card.
   *Test:* tap the card → screen, for a construction unit, a no-VP-date unit and a mid-journey unit.
2. **Edit your loan / your value** on the unit screen (`PUT …/numbers`), with the 422 message
   shown. *Test:* the assumed warning disappears after a save; a 40+ year tenure shows the server's
   message.
3. **The Home card's missing pieces**:
   - all five figures for every unit, with dashes
   - "Now: stage · until date"
   - next step + due date, overdue in rose
   - WhatsApp PIC
   - the renovation decision, highlighted
   - open on the unit that needs the member

   *Test:* a golden for each state in §3 marked 🔴.
4. **Change a stage date** on the unit screen (`PUT …/stages/{n}`). Hidden while `started` is
   false. *Test:* 409 hidden, 200 replaces the unit.
5. **Action Centre:** make the setup rows tappable; map the web paths; render `session` and
   `journey_step`; disable the renovation buttons while saving; use the PUT's response instead of a
   full reload. *Test:* one per kind.
6. **Upcoming:** render `sessions[]` beside `events[]`.
7. **KPI breakdown** (`portfolio_kpis.breakdown`, "N not valued yet").
8. **Customise:** reorder, hide, presets, reset (`PUT member/dashboard/layout`). Apply the saved
   layout on expanded widths too.

## 6. Decisions that are the owner's

- **Membership cards on Home.** The website shows them under the blocks; the app shows membership
  names on Profile. Keep that split, or bring the cards to Home? (The API would need the detail.)
- **The wealth goal card's figures.** Its PLACE is settled (2026-09-25: a block the member
  arranges, as on the portal). What it shows still differs: the app shows the plan's own goal
  track, the website the target / what the owned units carry / the gap.
- **In-app contact verification.** Today an unverified member is locked out of the app with a
  sentence. Building the website's overlay needs new member-api routes (an auth flow), so it is a
  separate piece of work.

## Related files

- `app/Http/Controllers/Concerns/ReadsMemberAgenda.php` — sessions, onboarding facts,
  `memberAttention()`; used by `Main\DashboardController` and `BuildMobileDashboard`.
- `app/Actions/PostVp/SaveOwnerNumbers.php` · `MoveOwnerStage.php` — the owner's writes, shared by
  `Main\Portal\MyPropertiesController` and `Api\v1\MobileMyPropertiesController`.
- `app/Http/Controllers/Concerns/OwnsPostVpUnits.php` — `ownedBooking()` is now the one lookup for
  every member-side write.
- `tests/Feature/Api/v1/MobileDashboardTest.php` · `MobileMyPropertiesTest.php`.
