feat(costs): consolidated Costs section on listing detail - #11
Conversation
The listing detail page scattered the actual cost of renting a
property across three places: the price card showed monthly rent,
the fineprint listed deposit / fees / service charge / ground rent,
and the council-tax table sat lower down. To compare two listings
you had to mentally aggregate all of these.
This PR flattens them into one ordered section with a 'true monthly'
total beneath, so the decision-relevant number is visible at a glance.
## What's in the total
- Rent (monthly).
- Council tax (monthly amount for the listing's specific band; if
the band is unknown, the row renders informational and the total
is flagged as partial — we don't guess a band).
- Service charge ÷ 12 (when stated; leasehold only).
- Ground rent ÷ 12 (same).
- Deposit amortised over the minimum term — a £3,200 deposit on a
12-month tenancy reads as '£267 · £3,200 over 12 months'. Skipped
when either side is unknown (better than amortising over a guess).
## What's NOT in the total (deliberately)
- Bills. The bills-included flag IS shown — "Included" when true,
"Extra" when false — but no estimate gets added to the total.
Bill amounts depend on usage and bedrooms in ways this layer
can't model honestly; surfacing a fake number would make two
listings look comparable when they aren't. When bills are
excluded the total is flagged 'partial'.
- Tenancy fees text. Illegal under the Tenant Fees Act 2019; the
existing fineprint section still shows the disclosure for the
renter to read.
## Two presentations of the same content
- <Costs> — mobile listing detail shell. Section + serif heading
+ bottom-spacing pattern, matches <PublicRecords> / <Fineprint>
siblings. Mounted on `/listings/$clusterId` between the portal
cross-list and the highlights — costs land right where the user
is thinking about money.
- <CostsCard> — desktop InfoColumn shell. Same internals wrapped
in the bordered card chrome to match <AiCard> / <RecordsCard>.
Mounted between <PriceCard> and <AiCard> in the desktop info
column.
Both share a single computeCosts() function — the rendered HTML on
desktop is identical to mobile except for the outer wrapper.
## Verification
- bun run typecheck ✅
- bun run lint:ci ✅
- bun run test — 298 / 298 pass (+11 new tests in
tests/components/costs.test.tsx)
The new tests use renderToStaticMarkup to drive the component end-to-
end and assert on the rendered HTML — covers rent-only, rent + each
cost type, deposit amortisation, deposit-without-term informational
path, bills-included vs not, authority-known-band-unknown partial
total, and the all-fields-present sum. `.tsx` test files needed a
one-line vitest config addition to the include glob — listed inline
with a note for future presentational tests.
`Fineprint` is unchanged — the council-tax table + agent contact
still surface there. The Costs section sits above it as the headline
summary; Fineprint stays as the detail backup.
ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (5)
WalkthroughAdds a new consolidated costs component to listing detail pages that displays rent, council tax, service charge, ground rent, and deposit in an ordered list with a computed monthly total. Integrates the component into both desktop and mobile listing views and includes a comprehensive test suite validating cost calculation and conditional rendering logic. ChangesConsolidated Costs Component
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login. Comment |
Closes item 6 of the improvement series. Last one.
Problem
The listing detail page scattered the actual cost of renting a property across three places:
To compare two listings you had to mentally aggregate all of these. The hidden answer to "what does this actually cost per month?" wasn't anywhere on the page.
Fix
New
<Costs>(mobile) /<CostsCard>(desktop) — one ordered section with a "true monthly" total beneath. Both share a singlecomputeCosts()function; the rendered HTML is identical except for the outer wrapper.What's in the total
priceMonthlycouncilTax.bands[listingBand].monthlyPounds— only when the band is knownserviceChargeAnnual / 12groundRentAnnual / 12deposit / minimumTermMonths— amortisedSkipping council tax when the band is unknown (vs falling back to Band D) is deliberate — £1,500/yr properties shouldn't get silently rounded up to £2,200/yr ones.
What's NOT in the total (deliberately)
Where it mounts
/listings/$clusterIdbetween the portal cross-list and the highlights — costs land where the renter is thinking about money.InfoColumnbetween<PriceCard>and<AiCard>, wrapped in the bordered-card chrome to match<RecordsCard>.Fineprintis unchanged — the full council-tax table + agent contact still surface there. Costs is the headline; Fineprint is the detail backup.Verification
bun run typecheck✅bun run lint:ci✅bun run test— 298 / 298 pass (+11 new tests intests/components/costs.test.tsx)Tests use
renderToStaticMarkupto drive the component end-to-end and assert on the rendered HTML — covers rent-only, rent + each cost type, deposit amortisation, deposit-without-term informational path, bills-included vs not, authority-known-band-unknown partial total, and an all-fields-present total of £2,507..tsxtest files needed a one-line vitest config addition to the include glob — there's a comment invitest.config.tsfor future presentational tests.Final state of the series
All six items now have open PRs:
Summary by CodeRabbit
New Features
Tests