diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..969fb79
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,4 @@
+{
+ "ruby.rubocop.useBundler": true,
+ "ruby.rubocop.commandPath": "bin/rubocop"
+}
diff --git a/Gemfile b/Gemfile
index de0f697..55ce6b9 100644
--- a/Gemfile
+++ b/Gemfile
@@ -51,7 +51,7 @@ group :development, :test do
gem "pry-rails"
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
- gem "brakeman", "~> 7.1.2", require: false
+ gem "brakeman", "~> 8.0.2", require: false
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 356bcbb..d429602 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -82,7 +82,7 @@ GEM
bindex (0.8.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
- brakeman (7.1.2)
+ brakeman (8.0.2)
racc
builder (3.3.0)
capybara (3.40.0)
@@ -378,7 +378,7 @@ PLATFORMS
DEPENDENCIES
bootsnap
- brakeman (~> 7.1.2)
+ brakeman (~> 8.0.2)
capybara
csv
debug
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index edbb54a..8d670dc 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -173,31 +173,15 @@ th a:hover {
}
/* Status styling */
-.status-submitted {
+.status-applied {
color: #0d6efd;
}
-.status-under_review {
- color: #fd7e14;
-}
-.status-phone_screen {
- color: #20c997;
-}
-.status-interview {
+.status-interviewing {
color: #6f42c1;
}
-.status-responded {
- color: #198754;
-}
-.status-offer {
- color: #198754;
- font-weight: bold;
-}
.status-closed {
color: #dc3545;
}
-.status-withdrawn {
- color: #6c757d;
-}
/* Dashboard Cards */
.dashboard-cards {
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 4e2d45e..6f6050d 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -1,10 +1,6 @@
class DashboardController < ApplicationController
def index
- @total_potentials = Opportunity.where(application_date: nil).count
@total_resumes = Opportunity.where.not(application_date: nil).count
- @total_responses = Opportunity.where(status: "responded").count
- @total_interviews = Opportunity.where(status: "interview").count
- @total_offers = Opportunity.where(status: "offer").count
@total_assessed = Opportunity.count
# Tech stack analytics
diff --git a/app/models/concerns/role_types/other.rb b/app/models/concerns/role_types/other.rb
new file mode 100644
index 0000000..b24750d
--- /dev/null
+++ b/app/models/concerns/role_types/other.rb
@@ -0,0 +1,15 @@
+module RoleTypes
+ module Other
+ extend ActiveSupport::Concern
+
+ # Other role metadata structure can be anything that doesn't fit the defined roles.
+
+ included do
+ validate :validate_other_role_metadata, if: -> { role_type == "other" }
+ end
+
+ def validate_other_role_metadata
+ # No required metadata for "other"
+ end
+ end
+end
diff --git a/app/models/concerns/role_types/support_engineer.rb b/app/models/concerns/role_types/support_engineer.rb
new file mode 100644
index 0000000..d53c423
--- /dev/null
+++ b/app/models/concerns/role_types/support_engineer.rb
@@ -0,0 +1,32 @@
+module RoleTypes
+ module SupportEngineer
+ extend ActiveSupport::Concern
+
+ # Support Engineer specific metadata structure:
+ # {
+ # support_tier: "l2", # l1, l2, l3
+ # on_call: true,
+ # ticket_volume: "high", # low, medium, high
+ # escalation_path: "engineering", # engineering, product, customer_success
+ # support_tools: ["zendesk", "jira"],
+ # shift_type: "rotating" # fixed, rotating, follow_the_sun
+ # }
+
+ included do
+ validate :validate_support_engineer_metadata, if: -> { role_type == "support_engineer" }
+ end
+
+ def validate_support_engineer_metadata
+ # Add any specific validations for support engineer roles
+ end
+
+ def support_summary
+ return "Not specified" unless role_metadata["support_tier"].present?
+
+ tier = role_metadata["support_tier"].upcase
+ on_call = role_metadata["on_call"] ? "On-call" : nil
+
+ [ "Tier #{tier}", on_call ].compact.join(" ⢠")
+ end
+ end
+end
diff --git a/app/models/opportunity.rb b/app/models/opportunity.rb
index 2c32458..4a8d69b 100644
--- a/app/models/opportunity.rb
+++ b/app/models/opportunity.rb
@@ -4,6 +4,8 @@ class Opportunity < ApplicationRecord
include RoleTypes::SalesEngineer
include RoleTypes::SolutionsEngineer
include RoleTypes::ProductManager
+ include RoleTypes::SupportEngineer
+ include RoleTypes::Other
belongs_to :company
has_many :opportunity_technologies, dependent: :destroy
@@ -19,7 +21,9 @@ class Opportunity < ApplicationRecord
"software_engineer" => "Software Engineer",
"sales_engineer" => "Sales Engineer",
"solutions_engineer" => "Solutions Engineer",
- "product_manager" => "Product Manager"
+ "product_manager" => "Product Manager",
+ "support_engineer" => "Support Engineer",
+ "other" => "Other"
}.freeze
validates :role_type, presence: true, inclusion: { in: ROLE_TYPES.keys }
@@ -48,6 +52,14 @@ def product_manager?
role_type == "product_manager"
end
+ def support_engineer?
+ role_type == "support_engineer"
+ end
+
+ def other?
+ role_type == "other"
+ end
+
# Get/Set role metadata with indifferent access
def metadata
@metadata ||= (role_metadata || {}).with_indifferent_access
diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb
index e2e2090..dd55577 100644
--- a/app/views/dashboard/index.html.erb
+++ b/app/views/dashboard/index.html.erb
@@ -64,22 +64,31 @@
<% end %>
-
-
Applications by Week
-
- <% @weekly_data.each_with_index do |week, index| %>
- <%= link_to opportunities_path(start_date: week[:start_date], end_date: week[:end_date]), class: "card card-week clickable-week-card", style: "text-decoration: none; color: inherit;" do %>
+
+
-
<%= week[:count] %>
-
applications
+
+ <% @weekly_data.each_with_index do |week, index| %>
+ <%= link_to opportunities_path(start_date: week[:start_date], end_date: week[:end_date]), class: "card card-week clickable-week-card", style: "text-decoration: none; color: inherit;" do %>
+
+
+
<%= week[:count] %>
+
applications
+
+ <% end %>
+ <% end %>
+
- <% end %>
- <% end %>
+
+
diff --git a/app/views/opportunities/_form.html.erb b/app/views/opportunities/_form.html.erb
index 8220490..0f09bd6 100644
--- a/app/views/opportunities/_form.html.erb
+++ b/app/views/opportunities/_form.html.erb
@@ -54,14 +54,9 @@
<%= form.label :status, style: "display: block" %>
<%= form.select :status,
options_for_select([
- ["Applied", "submitted"],
- ["Under Review", "under_review"],
- ["Phone Screen", "phone_screen"],
- ["Interview Scheduled", "interview"],
- ["Responded", "responded"],
- ["Offer Received", "offer"],
- ["Closed", "closed"],
- ["Withdrawn", "withdrawn"]
+ ["Applied", "applied"],
+ ["Interviewing", "interviewing"],
+ ["Closed", "closed"]
], opportunity.status),
{ include_blank: "Select status" },
{ style: "display: block; width: 250px; height: 30px;" } %>
@@ -115,6 +110,16 @@
<%= render "product_manager_fields", opportunity: opportunity %>
+
+
+
+ <%= render "support_engineer_fields", opportunity: opportunity %>
+
+
+
+
+ <%= render "other_fields", opportunity: opportunity %>
+
diff --git a/app/views/opportunities/_opportunity.html.erb b/app/views/opportunities/_opportunity.html.erb
index b0f8045..9b731b7 100644
--- a/app/views/opportunities/_opportunity.html.erb
+++ b/app/views/opportunities/_opportunity.html.erb
@@ -10,6 +10,10 @@
"#17a2b8" # Teal
when "product_manager"
"#6f42c1" # Purple
+ when "support_engineer"
+ "#fd7e14" # Orange
+ when "other"
+ "#343a40" # Dark
else
"#6c757d" # Default gray
end
@@ -22,7 +26,7 @@
font-size: 11px;
white-space: nowrap;
display: inline-block;
- width: 80px;
+ width: 95px;
text-align: center;
">
<%= opportunity.role_type_label.gsub("Engineer", "Eng").gsub("Manager", "Mgr") %>
diff --git a/app/views/opportunities/_opportunity_details.html.erb b/app/views/opportunities/_opportunity_details.html.erb
index 515f402..66d0d14 100644
--- a/app/views/opportunities/_opportunity_details.html.erb
+++ b/app/views/opportunities/_opportunity_details.html.erb
@@ -4,7 +4,25 @@
Role Type:
-
+ <%
+ badge_color = case opportunity.role_type
+ when "software_engineer"
+ "#6c757d"
+ when "sales_engineer"
+ "#28a745"
+ when "solutions_engineer"
+ "#17a2b8"
+ when "product_manager"
+ "#6f42c1"
+ when "support_engineer"
+ "#fd7e14"
+ when "other"
+ "#343a40"
+ else
+ "#6c757d"
+ end
+ %>
+
<%= opportunity.role_type_label %>
diff --git a/app/views/opportunities/_other_fields.html.erb b/app/views/opportunities/_other_fields.html.erb
new file mode 100644
index 0000000..c7f553e
--- /dev/null
+++ b/app/views/opportunities/_other_fields.html.erb
@@ -0,0 +1,5 @@
+
+
+ No role-specific fields for "Other".
+
+
diff --git a/app/views/opportunities/_support_engineer_fields.html.erb b/app/views/opportunities/_support_engineer_fields.html.erb
new file mode 100644
index 0000000..ab0972d
--- /dev/null
+++ b/app/views/opportunities/_support_engineer_fields.html.erb
@@ -0,0 +1,5 @@
+
+
+ No additional fields for Support Engineer roles yet.
+
+
diff --git a/app/views/opportunities/index.html.erb b/app/views/opportunities/index.html.erb
index 8d97a3e..239acae 100644
--- a/app/views/opportunities/index.html.erb
+++ b/app/views/opportunities/index.html.erb
@@ -36,6 +36,18 @@
#{ params[:role_type] == 'product_manager' ? 'background-color: #6f42c1; color: white;' : 'background-color: white; color: #333; border: 1px solid #dee2e6;' }",
onmouseover: "if (!this.style.backgroundColor.includes('rgb(111, 66, 193)')) { this.style.backgroundColor='#e9ecef'; }",
onmouseout: "if (!this.style.backgroundColor.includes('rgb(111, 66, 193)')) { this.style.backgroundColor='white'; }" %>
+
+ <%= link_to "Support Engineer", opportunities_path(role_type: "support_engineer"),
+ style: "padding: 6px 16px; border-radius: 4px; text-decoration: none; font-size: 14px; transition: all 0.2s; width: 160px; text-align: center;
+ #{ params[:role_type] == 'support_engineer' ? 'background-color: #fd7e14; color: white;' : 'background-color: white; color: #333; border: 1px solid #dee2e6;' }",
+ onmouseover: "if (!this.style.backgroundColor.includes('rgb(253, 126, 20)')) { this.style.backgroundColor='#e9ecef'; }",
+ onmouseout: "if (!this.style.backgroundColor.includes('rgb(253, 126, 20)')) { this.style.backgroundColor='white'; }" %>
+
+ <%= link_to "Other", opportunities_path(role_type: "other"),
+ style: "padding: 6px 16px; border-radius: 4px; text-decoration: none; font-size: 14px; transition: all 0.2s; width: 160px; text-align: center;
+ #{ params[:role_type] == 'other' ? 'background-color: #343a40; color: white;' : 'background-color: white; color: #333; border: 1px solid #dee2e6;' }",
+ onmouseover: "if (!this.style.backgroundColor.includes('rgb(52, 58, 64)')) { this.style.backgroundColor='#e9ecef'; }",
+ onmouseout: "if (!this.style.backgroundColor.includes('rgb(52, 58, 64)')) { this.style.backgroundColor='white'; }" %>
<% if @role_filter %>
diff --git a/db/migrate/20260203130000_normalize_opportunity_statuses.rb b/db/migrate/20260203130000_normalize_opportunity_statuses.rb
new file mode 100644
index 0000000..0459664
--- /dev/null
+++ b/db/migrate/20260203130000_normalize_opportunity_statuses.rb
@@ -0,0 +1,21 @@
+class NormalizeOpportunityStatuses < ActiveRecord::Migration[8.0]
+ def up
+ execute <<~SQL
+ UPDATE opportunities
+ SET status = 'applied'
+ WHERE status = 'submitted';
+
+ UPDATE opportunities
+ SET status = 'interviewing'
+ WHERE status IN ('under_review', 'phone_screen', 'interview', 'responded', 'offer');
+
+ UPDATE opportunities
+ SET status = 'closed'
+ WHERE status = 'withdrawn';
+ SQL
+ end
+
+ def down
+ raise ActiveRecord::IrreversibleMigration
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 15acd6e..46892f6 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2026_01_23_193352) do
+ActiveRecord::Schema[8.0].define(version: 2026_02_03_130000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
diff --git a/lib/tasks/update_proptech_industry.rake b/lib/tasks/update_proptech_industry.rake
new file mode 100644
index 0000000..c0ae813
--- /dev/null
+++ b/lib/tasks/update_proptech_industry.rake
@@ -0,0 +1,36 @@
+namespace :companies do
+ desc "Update Real Estate related industries to PropTech"
+ task update_proptech_industry: :environment do
+ patterns = [
+ "Real Estate",
+ "Real Estate Technology",
+ "Real Estate SaaS"
+ ]
+
+ companies_to_update = Company.where(
+ patterns.map { |pattern| "industry ILIKE ?" }.join(" OR "),
+ *patterns.map { |pattern| "%#{pattern}%" }
+ )
+
+ count = companies_to_update.count
+
+ if count.zero?
+ puts "No companies found with Real Estate related industries."
+ else
+ puts "Found #{count} #{"company".pluralize(count)} to update:"
+ companies_to_update.each do |company|
+ puts " - #{company.name}: '#{company.industry}' -> 'PropTech'"
+ end
+
+ print "\nProceed with update? (y/n): "
+ response = STDIN.gets.chomp.downcase
+
+ if response == "y"
+ updated = companies_to_update.update_all(industry: "PropTech")
+ puts "\nā Successfully updated #{updated} #{"company".pluralize(updated)} to PropTech"
+ else
+ puts "\nā Update cancelled"
+ end
+ end
+ end
+end
diff --git a/test/system/opportunities_test.rb b/test/system/opportunities_test.rb
index b93ad6e..0bed448 100644
--- a/test/system/opportunities_test.rb
+++ b/test/system/opportunities_test.rb
@@ -36,7 +36,7 @@ class OpportunitiesTest < ApplicationSystemTestCase
select @opportunity.company.name, from: "Company"
fill_in "Notes", with: @opportunity.notes
fill_in "Position title", with: @opportunity.position_title
- select "Under Review", from: "Status"
+ select "Interviewing", from: "Status"
check "Python"
select "Indeed", from: "Source"
click_on "Update Opportunity"