Tuesday, September 2, 2025

Three-Dimensional Property Status: Clarity Through Separation of Concerns

Paul (Founder)
Platform Development
Night time coding setup with multiple monitors showing code

On 2 September 2025, we fundamentally rethought how properties track their state. Rather than forcing everything into a single "status" field with awkward combined values like "Let - Not Advertising" or "Vacant - Under Offer," we separated status into three independent dimensions: Tenancy Status (occupancy), Marketing Status (advertising state), and Financial Status (revenue tracking). This architectural change reflects how letting agencies actually think about properties.

The problem with single-status fields is they conflate unrelated concerns. A property can be tenanted but advertised (tenant giving notice), or vacant but not advertised (awaiting repairs), or advertised but not generating revenue (viewings scheduled, no tenant yet). The previous design forced artificial status combinations that confused staff and limited workflow flexibility.

What Your Team Will Notice

On the property detail page, three distinct status cards now present property state clearly:

Tenancy Status Card:

  • Displays "Active" (green badge) or "Vacant" (amber badge)
  • Shows tenant name if occupied, or "No active tenancy" if vacant
  • Includes move-in date and contract end date for active tenancies
  • Action menu offers "View Tenancy Details" or "Add New Tenancy"

Marketing Status Card:

  • Displays current advertising state: Draft, Ready, Live, Paused, or Archived
  • Includes toggle switch to enable/disable portal syndication
  • Shows success ring animation when actively advertising
  • Action menu offers "Edit Advert," "View on Portals," or "Pause Advertising"

Financial Status Card:

  • Displays monthly rental revenue (MRR) and annual revenue (ARR)
  • Toggles between MRR and ARR views via button
  • Shows comparison to previous period ("+8% vs last month")
  • Action menu offers "View Payment History" or "Adjust Rent"

Each card operates independently. Staff can update marketing status without affecting tenancy status, or modify tenancy details without impacting financial tracking. This separation eliminates confusion and enables precise workflow management.

For reporting, the system now supports nuanced queries: "Show all tenanted properties not currently advertised" (identifying expiring tenancies), or "Show vacant properties advertised for over 30 days" (identifying marketing effectiveness issues), or "Show properties with active tenancies generating less than £1,000 MRR" (identifying repricing opportunities).

Under the Bonnet: Database Schema Changes

The migration replaced the single status column with three independent fields:

# db/migrate/20250902070317_add_property_status_dimensions.rb
class AddPropertyStatusDimensions < ActiveRecord::Migration[8.0]
  def change
    add_column :properties, :occupancy_status, :string, default: "vacant"
    add_column :properties, :marketing_status, :string, default: "draft"
    add_column :properties, :has_active_tenancy, :boolean, default: false

    add_index :properties, [:agency_id, :occupancy_status]
    add_index :properties, [:agency_id, :marketing_status]
    add_index :properties, [:agency_id, :has_active_tenancy]
  end
end

Before migrating existing data, we preserved the original status in a new column:

# db/migrate/20250902070839_copy_status_to_imported_status.rb
class CopyStatusToImportedStatus < ActiveRecord::Migration[8.0]
  def up
    add_column :properties, :imported_status, :string

    execute <<-SQL
      UPDATE properties
      SET imported_status = status
      WHERE status IS NOT NULL
    SQL
  end

  def down
    remove_column :properties, :imported_status
  end
end

This safety measure enables rolling back if needed, and provides a reference for mapping legacy statuses to the new dimensions. We can then write data migration scripts to interpret imported statuses:

# Mapping logic (run after migration, not in migration itself)
Property.where(imported_status: "Let STC").update_all(
  occupancy_status: "pending_tenant",
  marketing_status: "paused",
  has_active_tenancy: false
)

Property.where(imported_status: "Let").update_all(
  occupancy_status: "occupied",
  marketing_status: "archived",
  has_active_tenancy: true
)

The has_active_tenancy boolean flag enables fast database queries without joining to the tenancies table. It's denormalized data (derived from tenancy records), but dramatically improves query performance. When tenancies are created or ended, callbacks update this flag:

# app/models/tenancy.rb (future implementation)
class Tenancy < ApplicationRecord
  belongs_to :property

  after_save :update_property_tenancy_status
  after_destroy :update_property_tenancy_status

  private

  def update_property_tenancy_status
    property.update(
      has_active_tenancy: property.tenancies.active.exists?
    )
  end
end

Property Model: Status Constants and Validations

The Property model defines allowed values and validations for each dimension:

# app/models/property.rb
class Property < ApplicationRecord
  OCCUPANCY_STATUSES = %w[
    vacant
    occupied
    pending_tenant
    notice_period
  ].freeze

  MARKETING_STATUSES = %w[
    draft
    ready
    live
    paused
    archived
  ].freeze

  validates :occupancy_status, inclusion: { in: OCCUPANCY_STATUSES }
  validates :marketing_status, inclusion: { in: MARKETING_STATUSES }

  # Scopes for common queries
  scope :vacant, -> { where(occupancy_status: "vacant") }
  scope :occupied, -> { where(occupancy_status: "occupied") }
  scope :with_active_tenancy, -> { where(has_active_tenancy: true) }
  scope :advertised, -> { where(marketing_status: "live") }

  # Display methods
  def occupancy_badge_color
    case occupancy_status
    when "vacant" then "amber"
    when "occupied" then "green"
    when "pending_tenant" then "blue"
    when "notice_period" then "orange"
    end
  end

  def marketing_badge_color
    case marketing_status
    when "draft" then "gray"
    when "ready" then "blue"
    when "live" then "green"
    when "paused" then "orange"
    when "archived" then "gray"
    end
  end

  # Financial calculations
  def monthly_rental_revenue
    return 0 unless has_active_tenancy?
    current_tenancy&.monthly_rent || price || 0
  end

  def annual_rental_revenue
    monthly_rental_revenue * 12
  end
end

The constants serve as single sources of truth—controllers use them to populate dropdowns, tests use them for factories, and validations enforce them at the database boundary.

The scopes enable expressive queries:

# Find vacant properties advertised for over 30 days
Property.vacant.advertised.where("created_at < ?", 30.days.ago)

# Find properties with tenants giving notice
Property.where(occupancy_status: "notice_period").order(:lease_end_date)

# Find properties ready to advertise but not yet live
Property.where(marketing_status: "ready")

These queries are fast (indexed) and readable (business logic, not SQL).

Status Cards: Component Design

Each status dimension gets a dedicated card component. The Tenancy Status card:

<!-- app/views/shared/_property_tenancy_status_card.html.erb -->
<div class="rounded-sm border border-stroke bg-white px-7.5 py-6 shadow-default dark:border-strokedark dark:bg-boxdark">
  <div class="flex items-center justify-between">
    <div>
      <h4 class="text-title-md font-bold text-black dark:text-white">
        Tenancy Status
      </h4>
      <span class="mt-2 inline-flex rounded-full bg-<%= property.occupancy_badge_color %>-100 px-3 py-1 text-sm font-medium text-<%= property.occupancy_badge_color %>-800">
        <%= property.occupancy_status.titleize %>
      </span>
    </div>

    <!-- Three-dots action menu -->
    <div class="relative" x-data="{ open: false }">
      <button @click="open = !open" class="text-gray-500 hover:text-gray-700">
        <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
          <!-- Three vertical dots icon -->
        </svg>
      </button>

      <div x-show="open" @click.away="open = false" class="absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
        <% if property.has_active_tenancy? %>
          <%= link_to "View Tenancy Details", tenancy_path(property.current_tenancy), class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
        <% else %>
          <%= link_to "Add New Tenancy", new_property_tenancy_path(property), class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
        <% end %>
      </div>
    </div>
  </div>

  <div class="mt-4">
    <% if property.has_active_tenancy? %>
      <p class="text-sm text-gray-600 dark:text-gray-400">
        Tenant: <%= property.current_tenancy.tenant_name %>
      </p>
      <p class="text-sm text-gray-600 dark:text-gray-400">
        Move-in: <%= property.current_tenancy.start_date.strftime("%d %b %Y") %>
      </p>
      <p class="text-sm text-gray-600 dark:text-gray-400">
        End date: <%= property.current_tenancy.end_date.strftime("%d %b %Y") %>
      </p>
    <% else %>
      <p class="text-sm text-gray-600 dark:text-gray-400">
        No active tenancy
      </p>
    <% end %>
  </div>
</div>

The card uses Alpine.js (x-data, x-show, @click) for the action menu, maintaining consistency with the rest of the TailAdmin interface. The badge colour adapts to occupancy status, providing at-a-glance visual feedback.

The Marketing Status card (more complex, includes toggle switch and modal integration) follows similar patterns but adds the advertising controls introduced later in the week.

Testing Three-Dimensional Status

The test suite verifies status transitions and edge cases:

# spec/models/property_spec.rb
RSpec.describe Property do
  describe "status dimensions" do
    it "validates occupancy_status" do
      property = build(:property, occupancy_status: "invalid")
      expect(property).not_to be_valid
      expect(property.errors[:occupancy_status]).to include("is not included in the list")
    end

    it "allows independent status dimensions" do
      property = create(:property,
        occupancy_status: "occupied",
        marketing_status: "live",
        has_active_tenancy: true
      )

      expect(property.reload).to have_attributes(
        occupancy_status: "occupied",
        marketing_status: "live",
        has_active_tenancy: true
      )
    end
  end

  describe "scopes" do
    before do
      create(:property, occupancy_status: "vacant", marketing_status: "live")
      create(:property, occupancy_status: "occupied", marketing_status: "archived")
      create(:property, occupancy_status: "vacant", marketing_status: "paused")
    end

    it "finds vacant properties" do
      expect(Property.vacant.count).to eq(2)
    end

    it "finds advertised properties" do
      expect(Property.advertised.count).to eq(1)
    end

    it "chains scopes correctly" do
      result = Property.vacant.advertised
      expect(result.count).to eq(1)
      expect(result.first.occupancy_status).to eq("vacant")
      expect(result.first.marketing_status).to eq("live")
    end
  end

  describe "financial calculations" do
    it "calculates MRR for tenanted properties" do
      tenancy = create(:tenancy, monthly_rent: 1200)
      property = tenancy.property

      expect(property.monthly_rental_revenue).to eq(1200)
      expect(property.annual_rental_revenue).to eq(14400)
    end

    it "returns zero for vacant properties" do
      property = create(:property, has_active_tenancy: false)

      expect(property.monthly_rental_revenue).to eq(0)
    end
  end
end

These tests ensure status dimensions behave independently, scopes compose correctly, and financial calculations handle edge cases (vacant properties, missing tenancies).

Migration Strategy for Existing Agencies

For agencies with existing properties, migrating to three-dimensional status requires mapping legacy statuses:

# Migration script (run via rails console or data migration task)
Property.find_each do |property|
  # Interpret legacy status
  case property.imported_status
  when "Available to Let"
    property.update(occupancy_status: "vacant", marketing_status: "live")
  when "Let"
    property.update(occupancy_status: "occupied", marketing_status: "archived", has_active_tenancy: true)
  when "Let STC"
    property.update(occupancy_status: "pending_tenant", marketing_status: "paused")
  when "Under Negotiation"
    property.update(occupancy_status: "vacant", marketing_status: "live")
  when "Withdrawn"
    property.update(occupancy_status: "vacant", marketing_status: "archived")
  else
    # Default for unknown statuses
    property.update(occupancy_status: "vacant", marketing_status: "draft")
  end
end

This script runs once per agency during onboarding, ensuring existing data maps sensibly to the new structure.

Reporting and Analytics

The three-dimensional status system enables sophisticated reporting:

# Agency dashboard metrics
{
  total_properties: agency.properties.count,
  occupied_properties: agency.properties.occupied.count,
  vacant_properties: agency.properties.vacant.count,
  advertised_properties: agency.properties.advertised.count,
  occupancy_rate: (agency.properties.occupied.count.to_f / agency.properties.count * 100).round(1),
  total_mrr: agency.properties.sum(&:monthly_rental_revenue),
  average_rent: agency.properties.with_active_tenancy.average(:price)
}

These metrics power executive dashboards, franchise network reporting, and performance tracking—all made possible by clean status separation.

What's Next

The three-dimensional status foundation enables future features:

  • Workflow automation: Auto-pause advertising when tenancy starts
  • Expiry alerts: Notify staff when tenancies approach end dates
  • Marketing analytics: Track time-to-let by marketing status transitions
  • Revenue forecasting: Project annual revenue based on current tenancies and historical trends

But the core architecture—independent status dimensions that reflect real-world property states—was established on 2 September, providing clarity and flexibility for letting agency workflows.


Related articles: