From 9a26c349d70705a12d4e570029a947e51b55ab84 Mon Sep 17 00:00:00 2001 From: ntxtthomas Date: Wed, 2 Sep 2026 11:09:40 -0500 Subject: [PATCH 1/2] refined opportunity show, index, and csv + removed url shortener for company --- app/controllers/opportunities_controller.rb | 2 + app/models/company.rb | 17 +- app/services/opportunities_csv_exporter.rb | 16 +- app/views/opportunities/_opportunity.html.erb | 3 +- .../_opportunity_details.html.erb | 270 +++++++++--------- app/views/opportunities/index.html.erb | 3 +- spec/requests/opportunities_spec.rb | 19 ++ 7 files changed, 170 insertions(+), 160 deletions(-) diff --git a/app/controllers/opportunities_controller.rb b/app/controllers/opportunities_controller.rb index 77a9a06..7521d59 100644 --- a/app/controllers/opportunities_controller.rb +++ b/app/controllers/opportunities_controller.rb @@ -37,6 +37,8 @@ def index @opportunities = @opportunities.order("#{sort_column} #{sort_direction}") elsif sort_column == "company" @opportunities = @opportunities.joins(:company).order("companies.name #{sort_direction}") + elsif sort_column == "industry" + @opportunities = @opportunities.joins(:company).order("companies.industry #{sort_direction}") end else @opportunities = @opportunities.order(:application_date).reverse_order diff --git a/app/models/company.rb b/app/models/company.rb index 0de5dc6..52af848 100644 --- a/app/models/company.rb +++ b/app/models/company.rb @@ -9,7 +9,7 @@ class Company < ApplicationRecord has_many :resource_sheets, dependent: :nullify before_validation :normalize_name - before_save :shorten_urls, :sanitize_size + before_save :sanitize_size # Validations validates :name, presence: true @@ -77,21 +77,6 @@ def normalize_name self.name = name.to_s.squish.presence end - def shorten_urls - if website_changed? && website.present? && !website.include?("is.gd") - shortened = UrlShortenerService.shorten(website) - self.website = shortened if shortened.present? - end - - if linkedin_changed? && linkedin.present? && !linkedin.include?("is.gd") - shortened = UrlShortenerService.shorten(linkedin) - self.linkedin = shortened if shortened.present? - end - rescue StandardError => e - Rails.logger.error("URL shortening failed in Company#shorten_urls: #{e.message}") - # Continue with save even if shortening fails - end - def sanitize_size self.size = size&.gsub(",", "") if size.present? end diff --git a/app/services/opportunities_csv_exporter.rb b/app/services/opportunities_csv_exporter.rb index c496b18..b6c6f84 100644 --- a/app/services/opportunities_csv_exporter.rb +++ b/app/services/opportunities_csv_exporter.rb @@ -17,19 +17,14 @@ def generate csv << headers @opportunities.each do |opportunity| - # Combine structured technologies with other_tech_stack - tech_list = opportunity.technologies.order(:category, :name).pluck(:name).join(", ") - tech_list += ", #{opportunity.other_tech_stack}" if opportunity.other_tech_stack.present? - tech_list = tech_list.presence || opportunity.tech_stack # Fallback to old field if no structured data - + csv << [ + opportunity.role_type, opportunity.company.name, - opportunity.company.company_type || "", - opportunity.company.size || "", + opportunity.company.industry, opportunity.position_title, opportunity.application_date, opportunity.status, - tech_list, opportunity.salary_range, opportunity.chatgpt_match, opportunity.jobright_match, @@ -43,13 +38,12 @@ def generate def headers [ + "Role Type", "Company", - "Company Type", - "Company Size", + "Industry", "Position Title", "Application Date", "Status", - "Tech Stack", "Salary Range", "ChatGPT Match", "Jobright Match", diff --git a/app/views/opportunities/_opportunity.html.erb b/app/views/opportunities/_opportunity.html.erb index a61cf8f..2121d80 100644 --- a/app/views/opportunities/_opportunity.html.erb +++ b/app/views/opportunities/_opportunity.html.erb @@ -35,10 +35,10 @@ <%= opportunity.company.name if opportunity.company %> + <%= opportunity.company&.industry if opportunity.company %> <%= opportunity.position_title %> <%= opportunity.application_date %> <%= opportunity.status.present? && opportunity.status != "select" ? opportunity.status : "" %> - <%= opportunity.source.present? ? opportunity.source.humanize : "" %> <% tech_sources = opportunity.technologies.map { |t| { name: t.name, category: t.category, source: :opportunity } } %> <% if opportunity.other_tech_stack.present? %> @@ -83,7 +83,6 @@ <% end %> <%= opportunity.salary_range %> - <%= opportunity.bus_factor %> <%= opportunity.chatgpt_match %> <%= opportunity.jobright_match %> <%= opportunity.linkedin_match %> diff --git a/app/views/opportunities/_opportunity_details.html.erb b/app/views/opportunities/_opportunity_details.html.erb index 9127697..8ab1387 100644 --- a/app/views/opportunities/_opportunity_details.html.erb +++ b/app/views/opportunities/_opportunity_details.html.erb @@ -2,142 +2,154 @@

<%= opportunity.position_title %>

-

- 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 "success_engineer" - "#20c997" - when "other" - "#343a40" - else - "#6c757d" - end - %> - - <%= opportunity.role_type_label %> - -

- -

- Company: - <%= link_to opportunity.company.name, opportunity.company if opportunity.company %> -

- - <% if opportunity.company&.website.present? %> -

- Website: - <%= link_to opportunity.company.website, opportunity.company.website, target: "_blank", rel: "noopener noreferrer" %> -

- <% end %> - - <% if opportunity.company&.linkedin.present? %> -

- LinkedIn: - <%= link_to opportunity.company.linkedin, opportunity.company.linkedin, target: "_blank", rel: "noopener noreferrer" %> -

- <% end %> - -

- Application Date: - <%= opportunity.application_date %> -

- -

- Status: - <%= opportunity.status&.humanize %> -

- -

- Remote: - <%= opportunity.remote ? "Yes" : "No" %> -

- - <% if opportunity.technologies.any? %> -

- Technologies: -

- <% Technology::CATEGORIES.each do |category| %> - <% category_techs = opportunity.technologies.where(category: category).order(:name) %> - <% next if category_techs.empty? %> -
- <%= category %>: -
- <% category_techs.each do |tech| %> - <%= tech.name %> - <% end %> -
-
+
+
+
+ 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 "success_engineer" + "#20c997" + when "other" + "#343a40" + else + "#6c757d" + end + %> + + <%= opportunity.role_type_label %> + + +
+ +
+ Status: + + <%= opportunity.status&.humanize %> + +
+ +
+ Company: + <%= link_to opportunity.company.name, opportunity.company if opportunity.company %> +
+ +
+ Remote: + <%= opportunity.remote ? "Yes" : "No" %> +
+ + <% if opportunity.company&.website.present? %> +
+ Website: + <%= link_to opportunity.company.website, opportunity.company.website, target: "_blank", rel: "noopener noreferrer" %> +
+ <% end %> + + <% if opportunity.company&.linkedin.present? %> +
+ LinkedIn: + <%= link_to opportunity.company.linkedin, opportunity.company.linkedin, target: "_blank", rel: "noopener noreferrer" %> +
+ <% end %> + +
+ Created At: + <%= opportunity.created_at.strftime("%m/%d/%y") %> +
+ +
+ Application Date: + <%= opportunity.application_date&.strftime("%m/%d/%y") %> +
+ + <% if opportunity.source.present? %> +
+ Source: + <%= opportunity.source&.humanize %> +
+ <% end %> + + <% if opportunity.jobright_match.present? %> +
+ Jobright Match: + <%= opportunity.jobright_match %> +
+ <% end %> + + <% if opportunity.listing_url.present? %> +
+ Listing URL: + <%= link_to opportunity.listing_url, opportunity.listing_url, target: "_blank", rel: "noopener noreferrer" %> +
+ <% end %> + + <% if opportunity.chatgpt_match.present? %> +
+ ChatGPT Match: + <%= opportunity.chatgpt_match %> +
+ <% end %> + + <% if opportunity.salary_range.present? %> +
+ Salary Range: + <%= opportunity.salary_range %> +
+ <% end %> + + <% if opportunity.linkedin_match.present? %> +
+ LinkedIn Match: + <%= opportunity.linkedin_match %> +
<% end %>
-

- <% end %> - - <% if opportunity.other_tech_stack.present? %> -

- Other Technologies: - <%= opportunity.other_tech_stack %> -

- <% end %> - - <% if opportunity.source.present? %> -

- Source: - <%= opportunity.source&.humanize %> -

- <% end %> - - <% if opportunity.salary_range.present? %> -

- Salary Range: - <%= opportunity.salary_range %> -

- <% end %> - - <% if opportunity.listing_url.present? %> -

- Listing URL: - <%= link_to opportunity.listing_url, opportunity.listing_url, target: "_blank", rel: "noopener noreferrer" %> -

- <% end %> - - <% if opportunity.chatgpt_match.present? %> -

- ChatGPT Match: - <%= opportunity.chatgpt_match %> -

- <% end %> +
- <% if opportunity.jobright_match.present? %> -

- Jobright Match: - <%= opportunity.jobright_match %> -

- <% end %> + <% if opportunity.technologies.any? || opportunity.other_tech_stack.present? %> +
+
Technologies
+ <% Technology::CATEGORIES.each do |category| %> + <% category_techs = opportunity.technologies.where(category: category).order(:name) %> + <% next if category_techs.empty? %> +
+ <%= category %>: +
+ <% category_techs.each do |tech| %> + <%= tech.name %> + <% end %> +
+
+ <% end %> - <% if opportunity.linkedin_match.present? %> -

- LinkedIn Match: - <%= opportunity.linkedin_match %> -

+ <% if opportunity.other_tech_stack.present? %> +
+ Other: + <%= opportunity.other_tech_stack %> +
+ <% end %> +
<% end %> <% if opportunity.notes.present? %> -

- Notes: -

-
- <%= simple_format(opportunity.notes) %> +
+
Notes
+

==================================================================

+
+ <%= simple_format(opportunity.notes) %> +
<% end %> diff --git a/app/views/opportunities/index.html.erb b/app/views/opportunities/index.html.erb index a52be51..1d75e37 100644 --- a/app/views/opportunities/index.html.erb +++ b/app/views/opportunities/index.html.erb @@ -63,13 +63,12 @@ <%= sortable_link :role_type, "Role Type" %> <%= sortable_link :company %> + <%= sortable_link :industry, "Industry" %> <%= sortable_link :position_title, "Position" %> <%= sortable_link :application_date, "Application Date" %> <%= sortable_link :status %> - <%= sortable_link :source %> <%= sortable_link :tech_stack, "Tech Stack" %> <%= sortable_link :salary_range, "Salary Range" %> - <%= sortable_link :bus_factor, "Bus Factor" %> <%= sortable_link :chatgpt_match, "ChatGPT" %> <%= sortable_link :jobright_match, "Jobright" %> <%= sortable_link :linkedin_match, "LinkedIn" %> diff --git a/spec/requests/opportunities_spec.rb b/spec/requests/opportunities_spec.rb index f764aa9..ddd1331 100644 --- a/spec/requests/opportunities_spec.rb +++ b/spec/requests/opportunities_spec.rb @@ -83,6 +83,25 @@ expect(response.body).not_to include("Applied Role") expect(response.body).not_to include("Interviewing Role") end + + it "sorts opportunities by company industry" do + retail_company = Company.create!( + name: "RetailCo", + industry: "Retail", + company_type: "Product", + user: user + ) + Opportunity.create!( + company: retail_company, + position_title: "Retail Role", + role_type: "software_engineer" + ) + + get opportunities_path, params: { sort: "industry", direction: "asc" } + + expect(response).to have_http_status(:ok) + expect(response.body.index("Retail Role")).to be < response.body.index("Applied Role") + end end describe "GET /opportunities/new" do From 69a4543f0507d8185cd3b35e2dc903c587d198bb Mon Sep 17 00:00:00 2001 From: ntxtthomas Date: Wed, 2 Sep 2026 13:30:47 -0500 Subject: [PATCH 2/2] refactored technologies input UX/UI on Company and Opportunity --- app/assets/stylesheets/application.css | 6 +++ .../technology_picker_controller.js | 42 +++++++++++++++ app/models/technology.rb | 3 ++ app/services/opportunities_csv_exporter.rb | 1 - app/services/tech_stack_analyzer.rb | 2 +- app/views/companies/_form.html.erb | 13 +++-- app/views/opportunities/_form.html.erb | 10 +++- ...0260902120000_curate_technology_catalog.rb | 52 +++++++++++++++++++ db/schema.rb | 2 +- db/seeds.rb | 51 +++++++++++------- spec/requests/opportunities_spec.rb | 9 ++++ 11 files changed, 163 insertions(+), 28 deletions(-) create mode 100644 app/javascript/controllers/technology_picker_controller.js create mode 100644 db/migrate/20260902120000_curate_technology_catalog.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index daf0402..2742241 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -726,6 +726,12 @@ div[style*="color: red"], box-sizing: border-box; } +.opportunity-form-page .form-group textarea.technology-picker-input { + max-width: none !important; + height: auto !important; + padding: 0.5rem !important; +} + /* Skill Badges */ .skill-badge { display: inline-block; diff --git a/app/javascript/controllers/technology_picker_controller.js b/app/javascript/controllers/technology_picker_controller.js new file mode 100644 index 0000000..4a7a126 --- /dev/null +++ b/app/javascript/controllers/technology_picker_controller.js @@ -0,0 +1,42 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ "input", "checkbox", "result" ] + + apply() { + const pastedTechnologies = this.inputTarget.value + .split(/[;,\n]+/) + .map((technology) => technology.trim()) + .filter(Boolean) + + const checkboxesByName = new Map( + this.checkboxTargets.map((checkbox) => [ + this.normalize(checkbox.dataset.technologyName), + checkbox + ]) + ) + const unmatched = [] + let selected = 0 + + pastedTechnologies.forEach((technology) => { + const checkbox = checkboxesByName.get(this.normalize(technology)) + + if (checkbox) { + checkbox.checked = true + selected += 1 + } else { + unmatched.push(technology) + } + }) + + const messages = [] + if (selected > 0) messages.push(`${selected} ${selected === 1 ? "technology" : "technologies"} selected`) + if (unmatched.length) messages.push(`Not found: ${unmatched.join(", ")}`) + + this.resultTarget.textContent = messages.join(". ") || "Paste one or more technologies to match the catalog." + } + + normalize(technology) { + return technology.toLowerCase().replace(/\s+/g, " ").trim() + } +} \ No newline at end of file diff --git a/app/models/technology.rb b/app/models/technology.rb index 6459b9a..31848be 100644 --- a/app/models/technology.rb +++ b/app/models/technology.rb @@ -13,6 +13,9 @@ class Technology < ApplicationRecord "Testing", "DevOps/Infrastructure", "API/Integration", + "AI/LLM", + "Observability", + "Event/Messaging", "Other" ].freeze end diff --git a/app/services/opportunities_csv_exporter.rb b/app/services/opportunities_csv_exporter.rb index b6c6f84..d0b2762 100644 --- a/app/services/opportunities_csv_exporter.rb +++ b/app/services/opportunities_csv_exporter.rb @@ -17,7 +17,6 @@ def generate csv << headers @opportunities.each do |opportunity| - csv << [ opportunity.role_type, opportunity.company.name, diff --git a/app/services/tech_stack_analyzer.rb b/app/services/tech_stack_analyzer.rb index 134d1f5..eb79466 100644 --- a/app/services/tech_stack_analyzer.rb +++ b/app/services/tech_stack_analyzer.rb @@ -39,7 +39,7 @@ def analyze_main_stack_combinations combination_counts = Hash.new(0) # Main framework technologies to track - backend_frameworks = [ "Ruby on Rails", "Python", "Django", "Node.js", "Laravel", "Express" ] + backend_frameworks = [ "Ruby on Rails", "Python", "Django", "Node.js", "Express", "FastAPI" ] frontend_frameworks = [ "React", "Vue", "Angular", "Stimulus", "Hotwire" ] @opportunities.each do |opp| diff --git a/app/views/companies/_form.html.erb b/app/views/companies/_form.html.erb index 533a9a5..239a309 100644 --- a/app/views/companies/_form.html.erb +++ b/app/views/companies/_form.html.erb @@ -60,11 +60,18 @@ <%= form.url_field :linkedin, placeholder: "https://linkedin.com/...", class: "form-input" %>
-
+
<%= form.label :known_tech_stack, "Known Technologies", class: "form-label" %>

Technologies this company uses (from research, website, job postings, etc.)

+ +
+ + +
+

+ <% known_techs = company.known_tech_stack.present? ? company.known_tech_stack.split(",").map(&:strip) : [] %>
<% Technology::CATEGORIES.each do |category| %> <% techs = Technology.where(category: category).order(:name) %> @@ -75,9 +82,9 @@
<% techs.each do |tech| %> <% end %> diff --git a/app/views/opportunities/_form.html.erb b/app/views/opportunities/_form.html.erb index 6dd49ce..4688e52 100644 --- a/app/views/opportunities/_form.html.erb +++ b/app/views/opportunities/_form.html.erb @@ -71,8 +71,14 @@
-
+
<%= form.label "Technologies" %> + +
+ + +
+

<% Technology::CATEGORIES.each do |category| %> <% techs = Technology.where(category: category).order(:name) %> @@ -83,7 +89,7 @@
<% techs.each do |tech| %> <% end %> diff --git a/db/migrate/20260902120000_curate_technology_catalog.rb b/db/migrate/20260902120000_curate_technology_catalog.rb new file mode 100644 index 0000000..f93ae3a --- /dev/null +++ b/db/migrate/20260902120000_curate_technology_catalog.rb @@ -0,0 +1,52 @@ +class CurateTechnologyCatalog < ActiveRecord::Migration[8.0] + RETIRED_TECHNOLOGIES = [ + "Foundation", + "Nuxt.js", + "Svelte", + "Cassandra", + "Oracle", + "Minitest", + "Mocha", + "Chai", + "JUnit", + "Ansible", + "SendGrid", + "MariaDB", + "Flask", + "Laravel", + ".NET", + "C#", + "SQLite", + "LLM API Integration", + "OpenAI API" + ].freeze + + TECHNOLOGIES_BY_CATEGORY = { + "Backend" => [ "FastAPI", "Node.js", "Sidekiq" ], + "Database" => [ "SQL" ], + "Testing" => [ "FactoryBot", "Playwright" ], + "DevOps/Infrastructure" => [ "AWS EC2", "GitLab CI/CD" ], + "AI/LLM" => [ "LLM API", "RAG", "Vector Databases", "Agentic AI", "MCP", "Prompt Engineering" ], + "Observability" => [ "AWS CloudWatch", "New Relic", "Honeybadger", "Sentry", "AppSignal", "Datadog", "PostHog" ], + "Event/Messaging" => [ "Kafka", "RabbitMQ", "AWS SQS", "AWS SNS" ] + }.freeze + + def up + Technology.where(name: RETIRED_TECHNOLOGIES).find_each(&:destroy!) + + Company.where.not(known_tech_stack: [ nil, "" ]).find_each do |company| + technologies = company.known_tech_stack.split(",").map(&:strip) - RETIRED_TECHNOLOGIES + company.update_column(:known_tech_stack, technologies.join(", ")) + end + + TECHNOLOGIES_BY_CATEGORY.each do |category, names| + names.each do |name| + Technology.find_or_initialize_by(name: name).update!(category: category) + end + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/schema.rb b/db/schema.rb index cfe7d56..55633b5 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_08_24_213529) do +ActiveRecord::Schema[8.0].define(version: 2026_09_02_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" diff --git a/db/seeds.rb b/db/seeds.rb index 9e26b6f..c0c69ca 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -27,18 +27,16 @@ "Ruby on Rails", "Python", "Django", - "Flask", "Node.js", "Express", "Java", "Spring Boot", "PHP", - "Laravel", - ".NET", - "C#", "Go", "Elixir", - "Phoenix" + "Phoenix", + "FastAPI", + "Sidekiq" ], "Frontend" => [ "React", @@ -48,13 +46,10 @@ "TypeScript", "Tailwind", "Bootstrap", - "Foundation", "Stimulus", "Hotwire", "Turbo", "Next.js", - "Nuxt.js", - "Svelte", "HTML/CSS" ], "Database" => [ @@ -62,24 +57,19 @@ "MySQL", "MongoDB", "Redis", - "SQLite", - "MariaDB", "Elasticsearch", - "Cassandra", "DynamoDB", - "Oracle" + "SQL" ], "Testing" => [ "RSpec", "Jest", - "Minitest", "Capybara", "Cypress", "Selenium", - "Mocha", - "Chai", "PyTest", - "JUnit" + "FactoryBot", + "Playwright" ], "DevOps/Infrastructure" => [ "Docker", @@ -91,24 +81,45 @@ "AWS S3", "AWS SES", "AWS Lambda", + "AWS EC2", "Azure", "Google Cloud", "Heroku", "Terraform", - "Ansible", "GitHub Actions", + "GitLab CI/CD", "CircleCI" ], "API/Integration" => [ "RESTful APIs", "GraphQL", "gRPC", - "LLM API Integration", - "OpenAI API", "Stripe API", "Twilio", - "SendGrid", "Webhooks" + ], + "AI/LLM" => [ + "LLM API", + "RAG", + "Vector Databases", + "Agentic AI", + "MCP", + "Prompt Engineering" + ], + "Observability" => [ + "AWS CloudWatch", + "New Relic", + "Honeybadger", + "Sentry", + "AppSignal", + "Datadog", + "PostHog" + ], + "Event/Messaging" => [ + "Kafka", + "RabbitMQ", + "AWS SQS", + "AWS SNS" ] } diff --git a/spec/requests/opportunities_spec.rb b/spec/requests/opportunities_spec.rb index ddd1331..1382582 100644 --- a/spec/requests/opportunities_spec.rb +++ b/spec/requests/opportunities_spec.rb @@ -117,5 +117,14 @@ expect(response.body).to include("Upwork") expect(response.body).not_to include(">Monster<") end + + it "renders the paste tech stack control" do + get new_opportunity_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Paste Tech Stack") + expect(response.body).to include("data-controller=\"technology-picker\"") + expect(response.body).to include("data-action=\"technology-picker#apply\"") + end end end