DIY Rental Platform — Slice 6: Conversation Inbox, Tenant Overview, Tenant Offers

Slice 6 adds the two screens a rental user opens when they do not already know which record they want: a conversation inbox for both parties and a tenant Overview that answers "where am I, and what next?". It adds no tables, and it repairs two inherited defects — a settled-in tenant who cannot message anyone, and a landlord Offers page 404-ing in production.

  • Predecessor Slice 5 — Move-in handover (2026-08-28)
  • ADRs 0006
  • Status Approved

View source markdown ↗ generated by claude-opus-5 · diagrams mermaid

Slices 1–5 built every step of a tenancy and gave each step a page. Slice 6 adds the connective tissue — the two screens a user opens when they do not already know which record they want: a conversation inbox for both parties, and a tenant Overview that answers "where am I, and what do I do next?". It adds no tables, and it repairs two defects it inherits along the way.

  • Scope Inbox, tenant Overview, tenant Offers
  • Backend Go · internal/modules/rental
  • Frontend SolidStart · apps/rental
  • New tables None. One partial index.
  • Depends on Slices 2, 3, 4, 5
  • State Reviewed and settled; not implemented

What slice 6 proves

Three deliverables:

  1. A conversation inbox

    For both tenant and landlord — every thread in one place, with unread counts, reachable without first finding the right viewing request.

  2. A tenant Overview

    One card per property the tenant is progressing on, each showing the current step and the next action.

  3. A tenant Offers page

    Which requires an endpoint the frontend already calls and the backend never registered.

Success criteria

#Criterion
1A tenant with an active tenancy can message their landlord, from the inbox, even when that conversation holds no messages yet.
2Either party opens one inbox and sees every conversation, newest activity first, with unread counts.
3The nav badge reflects unread messages within 30 seconds while the tab is in the foreground and the threads fetch is succeeding.
4A tenant landing on /tenant sees one card per property they are progressing on, each showing the current step and next action.
5GET /rental/me/offers exists and both the tenant and landlord Offers pages work against the real route.
6A thread's writability is decided by one function; the inbox flag and the send guard cannot disagree.

Two inherited defects

Both are repaired here because slice 6 cannot deliver its own scope without touching exactly these two code paths.

Defect A — a settled-in tenant cannot message anyone

blocks the inbox
  • SendMessage rejects any request in a terminal status (viewing_request.go:194), and completed is terminal.
  • A viewing is marked completed before the offer, the agreement and the tenancy.
  • So by the time a relationship actually exists, every thread between the two parties is frozen.
  • An inbox over frozen threads is a filing cabinet, not a messaging feature.

Defect B — the landlord Offers page is 404-ing in production

live bug
  • features/rental/api/offers.ts:165 calls GET /rental/me/offers.
  • offer_handler.go:29-42 registers no list route.
  • The page's unit tests (landlordOffers.test.tsx:46) mock the whole API module, so the suite is green while the live page fails.
  • Slice 6 needs this endpoint anyway, so building it fixes the landlord page as a side effect.

Scope

In scope

  • GET /rental/me/threads — the inbox projection.
  • GET /rental/me/offers?status= — party-scoped offer list.
  • entity.IsThreadWritable — one rule, two callers.
  • ThreadRepository.RelationshipByRequestID — the single-request loader SendMessage needs, sharing its SQL fragments with ListForUser.
  • Relaxation of the SendMessage terminal guard per that rule.
  • One partial index supporting the unread count. The participant indexes the inbox predicate needs already exist.
  • Routes: /tenant (rewritten), /tenant/messages, /tenant/offers, /landlord/messages.
  • MessageThread.tsx extracted from ViewingRequestDetailView.tsx.
  • journeyProjection.ts — a pure client-side join producing journey cards.
  • badge?: number on RentalNavigationItem; unread dot on the mobile "More" button.
  • i18n keys for all new surfaces (en / ms / zh).

Out of scope, by decision

DeferredGoes toWhy
Saved-homes account syncSlice 7User decision. Currently localStorage in the Astro app.
Payment receipt PDFSlice 7User decision.
SSE / websocket pushLater, if everPolling is sufficient at this thread volume and adds no infrastructure.
Separate unread-count endpointLater, if everThe thread list is a few KB. Splitting it now is premature.
Move-out flow, deposit returnFuture sliceSlice 6 only keeps the thread open long enough to have the conversation.
Deposit / ledger data on the OverviewFuture sliceThe Overview states no money fact. It links to the tenancy detail page, which already holds the ledger.
A dedicated /messages/[id] routeNeverThe thread renders in the inbox's second pane and on the request detail page. A third location would be a third thing to keep in sync.

Deliberately not handled

  • Message editing or deletion. Not in slice 2, not added here.
  • Attachments in messages. Handover and payment proof already have purpose-built upload paths with their own retention rules. A general file drop into chat would bypass both.
  • Typing indicators, read receipts beyond read_at, reactions. Chat-app affordances with no bearing on a rental transaction.
  • Search across threads. A user has tens of threads, not thousands. Scrolling works.

The writability rule

A thread is writable when either the request is in a live status (pending, countered, confirmed), or the request is completed and the chain's resolved stage is still live.

One resolved stage, not three independent flags

The rule resolves ONE ChainStage — the furthest point the chain actually reached — and that stage alone decides.

entity.AgreementStatus has exactly three values: awaiting_signatures, completed, voided (domain/entity/agreement.go:12-18). Nothing ever moves a signed agreement out of completed, and every tenancy descends from a completed agreement. So under an OR, HasLiveAgreement is true for the rest of time, the grace check after it is unreachable, and ThreadGraceDays is dead code.

Rejected: three independent booleans (HasLiveOffer, HasLiveAgreement, TenancyEndsOn) combined with OR.

Resolved stageWritable when
tenancyInside the grace window. The tenancy's dates are authoritative; an earlier-stage offer or agreement row underneath it never overrides them.
agreementAgreement status is awaiting_signatures or completed, and the agreement row is not soft-deleted.
offerOffer status is pending, countered or accepted, and no agreement row exists on that offer.
noneNever.

The grace window is 30 days, on KL calendar dates

Deposit return is discussed after move-out. Freezing the thread the moment a tenancy ends would remove the channel at precisely the moment it is most needed. Thirty days is long enough for a deposit conversation and short enough that a long-ended tenancy does not linger forever.

The thread stays writable for the whole of day 1 through day 30 after the tenancy end date, and is frozen from the start of day 31 in Kuala Lumpur local time. ends_on Writable · 30 days Frozen Day 1 Day 30 Day 31 00:00 +08:00 cutoff
The cutoff is exclusive at day 31, so all of day 30 stays inside the window.

A tenancy ending 2026-09-30 is writable through the whole of 2026-10-30 in Asia/Kuala_Lumpur, and frozen from 2026-10-31 00:00 +08:00. This matches tenancyProjections.ts, which already compares KL calendar dates with toKLDateString and klCalendarDayDiff rather than raw instants.

The full domain contract — ChainStage, ThreadRelationship, IsThreadWritable
// ChainStage is the furthest point the request's chain actually reached.
// It is ONE resolved value, not a set of independent flags: a chain that
// reached a tenancy is judged as a tenancy, whatever rows sit beneath it.
type ChainStage string

const (
    ChainNone      ChainStage = "none"
    ChainOffer     ChainStage = "offer"
    ChainAgreement ChainStage = "agreement"
    ChainTenancy   ChainStage = "tenancy"
)

// ThreadRelationship carries the downstream facts IsThreadWritable needs.
// The repository resolves these in the same query that builds the thread row,
// so the rule stays a pure function over already-loaded state.
type ThreadRelationship struct {
    RequestStatus string
    Stage         ChainStage
    TenancyEndsOn *time.Time // set only when Stage == ChainTenancy
}

// ThreadGraceDays is how long after a tenancy ends the parties can still talk.
const ThreadGraceDays = 30

// klLocation is Asia/Kuala_Lumpur. ends_on is a calendar DATE, so the cutoff
// is built from its stored Y/M/D fields at KL midnight.
var klLocation = mustLoadKL()

// IsThreadWritable reports whether either party may still post to this thread.
// It is the ONLY expression of this rule. SendMessage enforces it; the inbox
// projection reports it.
func IsThreadWritable(rel ThreadRelationship, now time.Time) bool {
    switch rel.RequestStatus {
    case RequestStatusDeclined, RequestStatusWithdrawn, RequestStatusCancelled:
        // A party walked away deliberately. Nothing downstream reopens this.
        return false
    case RequestStatusPending, RequestStatusCountered, RequestStatusConfirmed:
        return true
    case RequestStatusCompleted:
        switch rel.Stage {
        case ChainTenancy:
            // The tenancy decides alone. An accepted offer or completed
            // agreement underneath it never extends the window.
            if rel.TenancyEndsOn == nil {
                return false
            }
            return withinGrace(*rel.TenancyEndsOn, now)
        case ChainAgreement, ChainOffer:
            // The repository only emits these stages for live rows, so
            // reaching here means the chain is still open.
            return true
        default: // ChainNone
            return false
        }
    default:
        return false
    }
}

// withinGrace is true while now is BEFORE KL midnight starting
// endsOn + ThreadGraceDays + 1. The exclusive day-31 bound keeps all of
// day 30 inside the window.
//
// endsOn's Y/M/D are used exactly as stored. Do NOT call endsOn.In(kl)
// first: a DATE arrives as midnight in the driver's nominal zone, and
// converting it can shift it to the previous or next calendar day.
func withinGrace(endsOn, now time.Time) bool {
    y, mo, d := endsOn.Date()
    // KL midnight on the day after the grace window's last day.
    cutoff := time.Date(y, mo, d+ThreadGraceDays+1, 0, 0, 0, 0, klLocation)
    return now.Before(cutoff)
}

SendMessage replaces its IsTerminalRequestStatus check with a call to ThreadRepository.RelationshipByRequestID plus this function, returning the existing ErrInvalidStateTransition when it is false. The HTTP mapping to 409 is unchanged. IsTerminalRequestStatus stays where it is — other call sites use it for viewing-request state transitions, which is a different question from thread writability.

One function, two callers

entity.IsThreadWritable is called by SendMessage as the enforcement guard, and by the me/threads projection to populate each thread's writable flag. If these diverge, the UI shows a composer that the server then rejects, or hides one the server would have accepted. A single function makes that class of bug unrepresentable. The server remains the authority; the flag is a UI affordance and never substitutes for the guard.

ViewingRequestUseCase has seven dependencies today, none of which can see an offer, an agreement or a tenancy. A new outbound port method carries the facts in:

// ThreadRepository
RelationshipByRequestID(ctx context.Context, requestID string) (entity.ThreadRelationship, error)

ThreadRepository becomes the eighth dependency. Rejected alternative: injecting the offer, agreement and tenancy repositories separately, which would put chain-resolution logic in the use case where it would drift from the ListForUser version of the same logic.

  1. Authorization first

    SendMessage loads the request and checks the caller is a party to it before calling RelationshipByRequestID. A caller must not be able to probe another user's chain state through timing or error shape.

  2. Shared SQL

    RelationshipByRequestID and ListForUser use the same chain fragment and the same fact mapping. An integration test asserts both return identical ThreadRelationship values for the same request, across each stage.

What the inbox lists, and what it never touches

A conversation is listed when it is live, has history, or is writable

#ConditionWhy
1The request is in a live status — pending, countered, confirmedA user can start a conversation about a viewing they just booked.
2The thread has at least one messageHistory worth keeping, whatever the request status.
3The request is completed and IsThreadWritable returns trueA conversation the user is allowed to start must be reachable.

Condition 3 exists because without it an empty writable conversation is unreachable. A completed request with a live tenancy and zero messages is writable, so the server accepts a message — but the user has nowhere to type it, short of navigating back to the old viewing request page. That is success criterion 1's exact scenario.

The scenario is reachable. Counter appends a negotiation message, and offer Create and Accept normally append one too, so a negotiated request almost always carries at least one message. Those appends are best-effort writes: when one fails the surrounding action still succeeds and the thread stays empty. A straight pending → confirmed → completed path with no counter also produces an empty thread.

Stays hidden
  • A declined, withdrawn or cancelled request with no messages. Never writable, and no history to keep. Without this a landlord with many listings accumulates dead rows.
  • A completed request whose tenancy is past the grace window and which holds no messages.
Stays visible
  • Any conversation with messages, including a declined one. History with something in it is never hidden.

Architecture

One rule in the domain; both the projection and the send guard route through it
One rule in the domain; both the projection and the send guard route through it.

Data model

No new tables. One new index, added in migrator_rental.go as CreateRentalSlice6Indexes, following the existing CreateRentalSliceNTables style and called from RunMigrations.

CREATE INDEX IF NOT EXISTS idx_rental_messages_unread
  ON rental_messages(request_id, sender_user_id)
  WHERE read_at IS NULL AND deleted_at IS NULL;

Renaming them to create near-duplicates was rejected: the existing indexes lead on exactly the participant columns the inbox predicate filters by (tenant_user_id = $1 OR owner_user_id = $1), so they are usable for it. No performance benefit is claimed for this choice and no planner behaviour is guaranteed. The unread partial index is the only addition, and it exists because no current index covers read_at IS NULL.

The existing idx_rental_messages_request (request_id, created_at ASC) already serves the last-message lookup and is not changed.

GET /rental/me/threads

  • counterparty_role is relative to the caller: the tenant sees "landlord", the landlord sees "tenant". The frontend splits one payload by this field.
  • r.confirmed_time is selected internally to gate the name. It is not in the response. No client needs it, and the gate is applied server-side before the payload is built.
  • last_message_* are null on a thread with no messages yet.
  • last_message_body is truncated server-side to 160 code points, not 160 bytes.
Response shape
{
  "threads": [
    {
      "request_id": "0198…",
      "listing_id": "0197…",
      "listing_title": "The Goodwood Residence",
      "listing_area": "Bangsar South",
      "counterparty_user_id": "usr_…",
      "counterparty_name": "Daniel Tan",
      "counterparty_role": "landlord",
      "request_status": "completed",
      "writable": true,
      "unread_count": 2,
      "last_message_body": "I'll bring the keys at 3pm.",
      "last_message_at": "2026-09-13T09:45:00Z",
      "last_message_sender_user_id": "usr_…"
    }
  ]
}
if r := []rune(body); len(r) > 160 {
    body = string(r[:160])
}

Two privacy gates

Conditioncounterparty_name
confirmed_time IS NULLextractFirstName(name) — the same helper the detail view uses before reveal
confirmed_time IS NOT NULLThe available full name
Name missing from usersRole label — "Landlord" / "Tenant"

No claim is made that first-name extraction is correct for every real name. Malaysian names frequently have no surname in the Western sense, and extractFirstName returns the leading token whatever the structure. It is a pre-reveal reduction, not a parse.

listing_title is composed server-side: building_name when non-empty, otherwise "{bedrooms}-bedroom {property_type} in {area}", with each part dropped when absent. This duplicates the Astro app's getListingTitle; the duplication is accepted, because extracting one label string into a shared cross-language package would cost more than it saves.

Query shape — one statement, three LEFT JOIN LATERAL
SELECT
  r.id, r.listing_id, r.status,
  r.confirmed_time,               -- internal only: gates the name, never serialised
  r.tenant_user_id, r.owner_user_id,
  p.building_name, p.area, p.property_type, p.bedrooms,
  m.body, m.created_at, m.sender_user_id,
  u.unread AS unread_count,
  COALESCE(rel.stage, 'none') AS chain_stage,
  rel.tenancy_ends_on
FROM rental_viewing_requests r
JOIN rental_listings l ON l.id = r.listing_id
JOIN rental_properties p ON p.id = l.property_id
LEFT JOIN LATERAL (
  SELECT body, created_at, sender_user_id
  FROM rental_messages
  WHERE request_id = r.id AND deleted_at IS NULL
  ORDER BY created_at DESC, id DESC
  LIMIT 1
) m ON TRUE
LEFT JOIN LATERAL (
  SELECT COUNT(*) AS unread
  FROM rental_messages
  WHERE request_id = r.id AND sender_user_id <> $1
    AND read_at IS NULL AND deleted_at IS NULL
) u ON TRUE
LEFT JOIN LATERAL (
  SELECT
    CASE WHEN t.id IS NOT NULL THEN 'tenancy'
         WHEN a.id IS NOT NULL THEN 'agreement'
         ELSE 'offer' END AS stage,
    t.ends_on AS tenancy_ends_on
  FROM rental_offers o
  LEFT JOIN rental_tenancy_agreements a ON a.offer_id = o.id
  LEFT JOIN rental_tenancies         t ON t.agreement_id = a.id
  WHERE o.request_id = r.id
    AND o.deleted_at IS NULL
    AND (
      (a.id IS NULL AND o.status IN ('pending','countered','accepted'))
      OR
      (a.id IS NOT NULL AND a.deleted_at IS NULL
                        AND a.status IN ('awaiting_signatures','completed'))
    )
  ORDER BY (t.id IS NOT NULL) DESC,
           (a.id IS NOT NULL) DESC,
           t.ends_on DESC NULLS LAST,
           o.created_at DESC, o.id DESC
  LIMIT 1
) rel ON TRUE
WHERE r.deleted_at IS NULL
  AND (r.tenant_user_id = $1 OR r.owner_user_id = $1)
  AND (
    r.status IN ('pending','countered','confirmed')   -- condition 1
    OR m.created_at IS NOT NULL                       -- condition 2
    OR r.status = 'completed'                         -- candidate for condition 3
  )
ORDER BY COALESCE(m.created_at, r.created_at) DESC, r.id DESC;
CaseResult
Agreement soft-deletedBoth branches false → chain excluded, no offer fallback
Agreement voidedStatus not in the list → chain excluded
Offer declined/withdrawn, no agreementExcluded
Tenancy under a valid agreementstage = 'tenancy', ends_on carried
No chain survivesLateral returns no row → stage = 'none'

rental_tenancies is joined with no predicate. It has no deleted_at column and no status column (migrator_rental.go:547-558), so there is nothing to filter and the projection must never reference t.deleted_at. Tie-breaks end on o.id and the outer ORDER BY ends on r.id, so both orderings are deterministic.

The SQL selects candidates; the domain rule decides

The WHERE clause is a candidate filter, not the visibility rule. It must not restate condition 3, because writability has exactly one expression and it lives in Go. So the query lets every completed request through, with or without messages, and the application filters afterwards:

for _, row := range rows {
    rel := entity.ThreadRelationship{
        RequestStatus: row.Status,
        Stage:         row.ChainStage,
        TenancyEndsOn: row.TenancyEndsOn,
    }
    writable := entity.IsThreadWritable(rel, now)   // already needed for the `writable` field

    live := entity.IsLiveRequestStatus(row.Status)
    if !live && row.LastMessageAt == nil && !writable {
        continue        // no live status, no history, not writable
    }
    out = append(out, toThread(row, writable))
}

The filter costs nothing extra: IsThreadWritable is already called for every row to populate the writable field. Reusing that one verdict is what keeps the inbox flag and the send guard incapable of disagreeing.

Declined, withdrawn and cancelled requests with no messages are excluded in SQL. That is not a duplicate of the writability rule — it is a set the rule provably rejects for every possible chain state, and those rows carry no history to preserve. The post-filter drops rows, so the returned count is smaller than the scanned count; there is no pagination on this endpoint, so nothing downstream depends on the two matching.

Counterparty display names are resolved in a second step via UserRepository.ContactsByIDs, batched over the distinct ids in the result set — one extra query for the whole page, never one per row.

GET /rental/me/offers

Mirrors the shape of the existing GET /me/agreements and GET /me/tenancies.

Filter
Optional ?status=, one of the five OfferStatus values. Unknown value → 400.
Scoping
Rows where tenant_user_id = session or landlord_user_id = session.
Ordering
updated_at DESC
Envelope
{"offers": [...]}
Schema change
None — rental_offers already carries every column needed.

The frontend's listMyOffers already tolerates both a bare array and this wrapped envelope (offers.ts:165-167), so the existing landlord page starts working with no client change. This endpoint has no dependency on the threads work and can ship on its own.

Frontend

RouteReplaces / addsContent
/tenantRewrites routes/tenant/index.tsxJourney cards
/tenant/messagesNewInbox
/tenant/offersNewOffers list, reusing OfferQueue in tenant role
/landlord/messagesNewInbox

One endpoint, two workspaces

GET /rental/me/threads is scoped to the session user, not to a role. A dual-role user — a landlord who also rents — gets every conversation from both sides in one payload. The frontend splits it by counterparty_role: /tenant/messages and the tenant badge show landlord threads; /landlord/messages and the landlord badge show tenant threads.

A second role-scoped endpoint was rejected. A role parameter on the server would add an authorization surface where a client-side filter over already-authorized rows is sufficient. Two consequences: cache keys include the user id (two accounts on one browser must not share a thread cache), and dual-role isolation is tested.

Inbox layout

Desktop: two panes, thread list left, selected thread right. Selection is local component state holding a request id — no route parameter, so there is no second URL representing a conversation. Mobile: the list fills the screen; selecting a thread replaces it, with a back control. Returning to the list clears the selection, which stops that conversation's poll.

The thread rendering currently inline in ViewingRequestDetailView.tsx:250-320 moves to MessageThread.tsx with props { messages, currentUserId }. Every data-testid moves with it unchanged, so the existing detail-view tests keep passing without edits — that is the check on the extraction.

Polling, not push

refetchInterval: 30_000 on the threads query, plus invalidation on send so a user's own message appears immediately. SSE would need a long-lived connection to survive the SolidStart API proxy, Fly.io idle timeouts, and reconnect/backoff — real infrastructure work, justified only by a latency requirement this product does not have.

RuleReason
No thread is auto-selected on loadAuto-selecting would mark a conversation read that the user never opened
Selection is by request id, held in local stateA thread is a request; nothing else identifies it
Only the explicitly selected, visible conversation polls, every 30 sOne open conversation, one read-marking fetch
Polling pauses when document.hidden, and on mobile when the view returns to the listA hidden conversation must not consume its own unread state
Focus refetch is disabled while the conversation is hiddenSame reason
Reopening the conversation refetches immediatelyThe user expects current messages on open

The tenant Overview joins on the client

journeyProjection.ts is a pure function over five sources, not four: requests, me/offers, me/agreements, me/tenancies, and handover reports. The fifth is the correction — listHandoverReports takes a single tenancy id (features/rental/api/handover.ts:309-314), so handover data arrives as N requests, one per eligible tenancy, and the projection receives a map keyed by tenancy id.

An aggregate GET /rental/me/journey was rejected: it would duplicate, server-side, a join the client already performs, and would need its own cache invalidation on every step transition in five slices' worth of endpoints. This follows tenancyProjections.ts and paymentSignals.ts, which already do exactly this kind of join with unit tests and no network.

Card identity is the listing, after chaining

A tenant can have several requests on the same listing over time — one declined in March, one live in September. Keying cards by request id would render both, contradicting success criterion 4. The order of operations is fixed:

  1. Chain

    Build candidate chains by id: request_id → offer_id → agreement_id → tenancy.

  2. Filter

    Drop chains that are genuinely terminal — declined/withdrawn/cancelled request, voided agreement, tenancy past grace — using known facts only. A chain whose evidence is unavailable is not dropped; it is marked.

  3. Group

    Group the survivors by listing_id.

  4. Select

    Within each group take the most advanced chain. Tie-break by latest activity, then by request id ascending, so the result is deterministic.

The journeyProjection.ts contract
// Each member names the evidence that selects it, and nothing more.
// No member asserts money settled, tenant acceptance, or that the
// tenancy is active or ended.
export type JourneyStep =
  | 'viewing'            // a request, nothing downstream
  | 'agreement'          // a live offer, or an agreement with no tenancy
  | 'moving_in'          // tenancy exists, no move-in report
  | 'handover_review'    // move-in report exists, not finalized
  | 'move_in_recorded';  // move-in report finalized

// A journey whose progress cannot be determined from known facts.
// This is NOT a step. It suppresses every progress claim and every action.
export const UNKNOWN_PROGRESS = null;

export type JourneyAction =
  | 'view_request'
  | 'review_offer'
  | 'sign_agreement'
  | 'review_handover'      // → the handover review route, keyed by REPORT id
  | 'view_payment_details' // → the tenancy detail route, keyed by TENANCY id
  | 'open_messages';

export interface JourneyCard {
  listingId: string;             // identity: one card per listing
  listingTitle: string;
  requestId: string;             // non-null: every selected chain starts at a request
  tenancyId: string | null;      // set from `moving_in` onward
  // null means UNKNOWN, never "the earliest step we can still justify".
  step: JourneyStep | null;
  // Always null when step is null. An action is a claim about what comes next,
  // and nothing can be claimed about an unknown position.
  action: JourneyAction | null;
  href: string | null;           // the action's target; null when action is null
  // Present whenever tenancyId is set, independent of `action`. Money is always
  // one click away, even when the suggested next action is the handover.
  paymentDetailsHref: string | null;
  blocked: boolean;              // this card's own detail source is unavailable
}

export interface JourneyResult {
  cards: JourneyCard[];
  degradedSources: string[];     // page-level: which sources are unavailable
}

// Every source arrives as an explicit state, not a bare array. A bare array
// cannot distinguish "no rows" from "we could not load it", and that
// distinction decides whether a card states a step or states nothing.
export type SourceSlot<T> =
  | { state: 'loading' }
  | { state: 'error' }
  | { state: 'success'; data: T };

export type HandoverSlot = SourceSlot<HandoverReportSummary[]>;

export function projectJourneys(input: {
  requests:   SourceSlot<ViewingRequest[]>;
  offers:     SourceSlot<Offer[]>;
  agreements: SourceSlot<TenancyAgreement[]>;
  tenancies:  SourceSlot<Tenancy[]>;
  // Per-tenancy. A tenancy id absent from the map is 'loading',
  // never an empty report list.
  handoversByTenancyId: Record<string, HandoverSlot>;
}): JourneyResult;

Step resolution, most advanced wins

EvidenceStepActionhref
Tenancy exists, move-in report finalizedmove_in_recordedview_payment_details/tenant/tenancies/{tenancyId}
Tenancy exists, move-in report exists, not finalizedhandover_reviewreview_handover/tenant/handover/{reportId}
Tenancy exists, no move-in report (successful empty list)moving_inview_payment_details/tenant/tenancies/{tenancyId}
Agreement exists, no tenancyagreementsign_agreement/tenant/agreements/{agreementId}
Live offer, no agreementagreementreview_offer/tenant/requests/{requestId}/offer
Request onlyviewingview_request/tenant/requests/{requestId}
Any source this chain needs is loading or errornullnullnull

The three tenancy rows are ordered by evidence: no report → report filed → report finalized. moving_in applies only to a successful, empty report list. A loading or error slot is unknown progress, never moving_in. HandoverReportSummary includes report_type, so slice 6 reads only move_in reports.

The report id comes from the HandoverReportSummary the projection already holds, so no new data source is needed. The link target is the tenancy detail page, not /tenant/tenancies/{id}/pay — the detail page holds the ledger, and it is what decides whether paying is the next thing at all.

What these labels do not say

Each label names an observation about a move-in report. None of them asserts anything further:

  1. Not tenancy status. Scheduled, active and ended come from the KL calendar-date comparison in tenancyProjections.ts, never from the handover. The two are shown as separate facts, and the move-in progress label is never rendered as the card's headline state on its own — a tenancy that never had a report filed would otherwise read "Moving in" months after the tenant moved in.
  2. Not financial settlement. A handover report records condition, not payment. The Overview states no money fact; paymentDetailsHref sends the tenant to the ledger to see it.
  3. Not tenant acceptance. Finalization has two reasons — tenant_accepted and deadline_elapsed (migrator_rental.go:824-842). move_in_recorded is deliberately neutral between them.
  4. Not termination. Nothing here says a tenancy ended.

Cards sort by step descending, then by most recent activity, then by request id. A card with step: null sorts last.

Action labels and navigation

action is a narrow union, and the view maps it with an exhaustive switch to static Paraglide function calls, exactly like getTermLabel in OfferTermsComparison.tsx:24-43. The reason is type safety, an explicit list of supported actions, and consistency with the existing repository pattern. No tree-shaking behaviour is asserted.

const getActionLabel = (a: JourneyAction): string => {
  switch (a) {
    case 'view_request':         return m.rental_journey_action_view_request();
    case 'review_offer':         return m.rental_journey_action_review_offer();
    case 'sign_agreement':       return m.rental_journey_action_sign_agreement();
    case 'review_handover':      return m.rental_journey_action_review_handover();
    case 'view_payment_details': return m.rental_journey_action_view_payment_details();
    case 'open_messages':        return m.rental_journey_action_open_messages();
  }
};

paymentDetailsHref needs no action member of its own. It is a secondary link on every tenancy card, labelled once, shown whenever tenancyId is set.

RentalNavigationItem gains badge?: number. When badge is a positive number the link renders a count; zero and undefined render nothing. Post-slice-6 both roles overflow the mobile nav, so the tenant sees Overview / Viewings / Messages / Offers directly with Agreements and Tenancies in "More", and the landlord sees Dashboard / Properties / Viewings / Messages with Offers, Tenancies and Payments in "More".

Error handling

CaseBehaviour
POST to a non-writable threadErrInvalidStateTransition → 409. The flag hides the composer; the server decides.
Counterparty absent from usersFall back to the role label ("Landlord" / "Tenant"). A deleted account must not 500 the inbox.
me/threads fails on first loadInbox shows a retry card. The badge renders nothing — never a zero, never a stale count.
me/threads background refetch failsThe thread list keeps the last good data. The badge still renders nothing while the current fetch is errored, because a count that may be wrong is worse than no count. No toast.
me/offers failsError card with retry, matching the landlord page.
A journey list source is unavailableAdd it to degradedSources. Render the cards that still resolve, showing only known display facts. Affected cards get step: null and action: null. Never state a step the missing source could contradict.
One tenancy's handover fetch failsMark that card blocked: true, step: null, action: null, with an inline warning. It does not fall back to an earlier step — an earlier step is a confident claim, and the evidence for it is exactly what is missing. Other cards are unaffected.
A handover fetch is still loadingSame as failed: unknown progress. Never "no report", never an earlier step.
?status= not a valid OfferStatus400 with the existing validation error shape.

Empty states are required on all three new surfaces. A new seeker has no threads, no offers and no journeys, and that is the first thing they will see.

Testing

Full test matrix
LayerCoverage
Go domainIsThreadWritable table test: every request status × every ChainStage. Grace boundary at day 29 (writable), day 30 (writable), day 31 (frozen).
Go domain, timezoneKL is UTC+8, so KL is always ahead. The case that catches a UTC comparison is an instant still day 30 in UTC while KL has already entered day 31 — it must be frozen. Plus both edges: 23:59:59 +08:00 on day 30 → writable; 00:00:00 +08:00 on day 31 → frozen.
Go domain, DATE mappingAn ends_on read back as midnight UTC produces the same verdict as the same date read back as midnight KL.
Go domainA completed request at ChainTenancy past grace stays frozen even with an accepted offer, a completed agreement, or a newer pending offer row present.
Go repositoryThe chain lateral: soft-deleted agreement → stage = 'none', not 'offer'. voided agreement → 'none'. Deleted offer ignored. Multiple candidate chains → deterministic winner.
Go repositoryRelationshipByRequestID and ListForUser return identical ThreadRelationship values for the same request, at each stage.
Go handler, real PostgresGET /me/threads: party scoping, unread counts, ordering by last activity, null last-message fields.
Go handler, listing ruleA completed request with no messages and a live tenancy is listed, with writable: true. The same request past grace is not listed. A declined request with no messages is not listed. A declined request with messages is listed, with writable: false.
Go handler, listing ruleThe SQL candidate set includes empty completed rows, and the Go post-filter drops the ones the domain rule finds unwritable.
Go handler, i18n-safeA 200-code-point Chinese message returns exactly 160 code points of valid UTF-8, not 160 bytes.
Go handler, privacyPayload contains no address_line, including on a thread whose viewing was declined before contact reveal.
Go handler, privacycounterparty_name is the first-name form while confirmed_time is null, and the full name after confirmation.
Go handler, labellisting_title uses building_name when set, and the composed fallback when it is empty.
Go handler, real PostgresGET /me/offers: party scoping, status filter, invalid status → 400, envelope shape. Hits the registered route, not a mock.
Go regressionSendMessage on completed + live tenancy → 200. On declined → 409. On completed + tenancy past grace → 409.
Go regressionSendMessage authorization runs before the relationship load: a non-party gets the same rejection whatever the chain state.
Go regressionThe inbox query performs no writes: read_at is unchanged after a me/threads call on a thread with unread messages.
Vitest, pureprojectJourneys: each step transition, several parallel journeys, terminal-chain exclusion.
Vitest, pureOne card per listing: a historical declined request plus a current live request on the same listing yields exactly one card, carrying the live chain's requestId.
Vitest, pureA loading or error handover slot never resolves to "no report". The card gets step: null and action: null — not advanced, not demoted.
Vitest, pureAn error list source populates degradedSources, and every card that needed it has step: null. Cards that did not need it are unaffected.
Vitest, pureThe three post-agreement labels, each selected by its own evidence: tenancy + successful empty report list → moving_in; report present, not finalized → handover_review; finalized → move_in_recorded.
Vitest, puremove_in_recorded is returned for both finalization_reason values.
Vitest, purepaymentDetailsHref is present on every card with a non-null tenancyId, including handover_review cards. Null on viewing and agreement cards.
Vitest, purereview_handover's href carries the handover report id, not the tenancy id. A fixture with different ids for the two catches a swap.
Vitest, pureThe move-in progress label and the date-derived tenancy status are independent; neither value overwrites the other.
Vitest, componentInbox renders and sorts threads; no thread selected on load; badge sums only the workspace's counterparty_role; badge renders nothing while the threads fetch is errored; composer disabled when writable: false; "More" dot appears when an overflow item has unread.
Vitest, componentOpening a thread refetches threads rather than writing zero: when the refetched payload still reports unread, the badge shows it.
Vitest, componentDual-role isolation: one user who is landlord on one listing and tenant on another sees each conversation in exactly one workspace.
Vitest, regressionViewingRequestDetailView tests pass unchanged after the MessageThread extraction.
E2E (ego lite)Tenant sends a message → landlord inbox shows unread → landlord opens the thread → count clears.

Task ordering

Work forms three groups. Within each group the order is fixed. The groups are not all independent.

GroupDepends on
A — the send fixNothing
B — the offers endpointNothing
C — the inbox and OverviewGroup A step 2 (the chain SQL), and Group B step 4 for the Overview's offer data

Only Group B ships entirely on its own. Group A ships on its own too, but it is a prerequisite for Group C rather than a peer of it.

Group A — the send fix (Defect A)

  1. ChainStage, ThreadRelationship, IsThreadWritable + domain tests.
  2. ThreadRepository.RelationshipByRequestID (the chain SQL) + repository tests, and wire ThreadRepository into ViewingRequestUseCase.
  3. SendMessage delegates to the rule; regression tests. Defect A repaired here.

Group B — the offers endpoint (Defect B)

  1. GET /me/offers + handler test hitting the registered route. Defect B repaired here.
  2. /tenant/offers page.

Group C — the inbox and Overview

  1. idx_rental_messages_unread in migrator_rental.go.
  2. ThreadRepository.ListForUser + GET /me/threads + handler tests. Needs Group A step 2 — it reuses the same chain SQL and fact mapping.
  3. MessageThread.tsx extraction; existing detail-view tests must stay green. Needs nothing; can start any time.
  4. badge on RentalNavigationItem; "More" unread dot.
  5. /tenant/messages and /landlord/messages, split by counterparty_role.
  6. journeyProjection.ts + pure tests. Needs Group B step 4 — the Overview reads me/offers.
  7. /tenant Overview rewrite.
  8. Cross-cutting: i18n keys en / ms / zh, then E2E last.

Product questions, answered

Two product choices were open during review. Both are now decided. They are recorded here with what was rejected, so a later reader does not re-open them by accident.

Q7 — the step between agreement and handover

Selected

Neutral move-in labels + payment link

Labels
moving_in, handover_review, move_in_recorded
Data source
Existing tenancy + move-in handover facts
New fetches
None
Money claim
None — links to the ledger

Selected by move-in report evidence alone. Every tenancy card carries paymentDetailsHref to /tenant/tenancies/{tenancyId}, where the ledger already lives.

Rejected

A deposits step

Data source
Per-tenancy ledger fetch (a sixth source)
New fetches
N, one per tenancy
Money claim
"These payments are outstanding"
Failure risk
A wrong ledger reading states a false money fact

The original draft inferred deposits from the absence of a handover report. That inference does not hold: a missing report is evidence about a report, and a finalized handover says nothing about settlement either.

F1 — a writable conversation with no messages

Selected

Yes — it is listed

Rule
Live, OR ≥1 message, OR completed AND writable
Where filtered
Go, after the domain rule
Criterion 1
Holds through the inbox

The SQL widens the candidate set and decides nothing, which keeps one copy of the writability rule. Empty expired and empty declined conversations stay hidden; a declined request that has messages stays visible.

Rejected

Keep the message-only filter

Rule
Live, OR ≥1 message
Criterion 1
Holds only via the detail page
Cost
None

Rejected because the request detail page is not where a tenant looks for a conversation. Also rejected: rel.stage IS NOT NULL as the SQL predicate — it lists expired tenancies past the grace window, and it is a second, weaker copy of the writability rule.