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:
