diff --git a/config.yaml b/config.yaml
index e7a412a..e5a878a 100644
--- a/config.yaml
+++ b/config.yaml
@@ -72,10 +72,6 @@ companies:
platform: greenhouse
board_token: automatticcareers
- - name: Canonical
- platform: greenhouse
- board_token: canonical
-
- name: ClickHouse
platform: greenhouse
board_token: clickhouse
diff --git a/dashboard.py b/dashboard.py
index cbe77d2..aa809ba 100644
--- a/dashboard.py
+++ b/dashboard.py
@@ -3,168 +3,193 @@
Generate a simple text-based HTML dashboard of all tracked jobs.
"""
-import re
from datetime import datetime
from pathlib import Path
+from collections import Counter
from db import Database
-# Regions/locations we care about (case-insensitive matching)
-DESIRED_REGIONS = [
- "canada", "toronto", "vancouver",
- "germany", "berlin", "munich",
- "emea",
- "americas", # includes North/South America
- "north america",
- "worldwide", "global", "anywhere",
-]
-
-# Locations to explicitly exclude (on-site or remote restricted to these)
-EXCLUDED_LOCATIONS = [
- # US cities/states (we don't want US-only jobs)
- "san francisco", "new york", "nyc", "seattle", "austin", "boston",
- "chicago", "denver", "los angeles", "atlanta", "dallas", "houston",
- "california", "washington", "texas", "massachusetts", "colorado",
- "united states", "usa", "u.s.", "us-", "usa-",
+# Location grouping rules: keyword -> (group_id, display_name)
+# Order matters - first match wins
+LOCATION_RULES = [
+ # Canada
+ (["canada", "toronto", "vancouver", "montreal", "ottawa", "calgary", "waterloo"], "canada", "Canada"),
+ # Germany
+ (["germany", "berlin", "munich", "frankfurt", "hamburg"], "germany", "Germany"),
# UK
- "london", "united kingdom", "uk", "dublin", "ireland",
- # Australia/APAC (not EMEA)
- "sydney", "melbourne", "australia", "singapore", "tokyo", "japan",
- "india", "bangalore", "bengaluru", "hyderabad", "delhi",
- "korea", "seoul", "taiwan", "taipei", "china", "beijing", "shenzhen",
- # Other excluded
- "israel", "tel aviv", "brazil", "sao paulo", "mexico",
- "netherlands", "amsterdam", "france", "paris", "spain", "madrid",
- "portugal", "lisbon", "poland", "warsaw", "italy",
- "czech", "prague", "serbia", "belgrade", "cyprus", "limassol",
- "austria", "vienna", "sweden", "stockholm", "denmark", "copenhagen",
- "switzerland", "romania", "bucharest", "hungary", "greece",
- "south africa", "indonesia", "jakarta", "malaysia",
+ (["united kingdom", " uk", "uk ", "london", "england", "manchester", "edinburgh"], "uk", "UK"),
+ # Ireland
+ (["ireland", "dublin"], "ireland", "Ireland"),
+ # Netherlands
+ (["netherlands", "amsterdam", "rotterdam"], "netherlands", "Netherlands"),
+ # France
+ (["france", "paris"], "france", "France"),
+ # Spain
+ (["spain", "madrid", "barcelona"], "spain", "Spain"),
+ # Poland
+ (["poland", "warsaw", "krakow", "wroclaw"], "poland", "Poland"),
+ # Sweden
+ (["sweden", "stockholm"], "sweden", "Sweden"),
+ # Switzerland
+ (["switzerland", "zurich", "geneva"], "switzerland", "Switzerland"),
+ # Australia
+ (["australia", "sydney", "melbourne"], "australia", "Australia"),
+ # India
+ (["india", "bangalore", "bengaluru", "hyderabad", "delhi", "mumbai", "pune"], "india", "India"),
+ # Japan
+ (["japan", "tokyo"], "japan", "Japan"),
+ # Singapore
+ (["singapore"], "singapore", "Singapore"),
+ # Israel
+ (["israel", "tel aviv"], "israel", "Israel"),
+ # Brazil
+ (["brazil", "sao paulo"], "brazil", "Brazil"),
+ # US (must be after other countries to avoid false matches)
+ (["united states", "usa", "u.s.", "san francisco", "new york", "nyc", "seattle",
+ "austin", "boston", "chicago", "denver", "los angeles", "atlanta", "dallas",
+ "houston", "california", "washington", "texas", "massachusetts", "colorado",
+ "portland", "miami", "phoenix", "san diego", "san jose", "palo alto",
+ "mountain view", "sunnyvale", "menlo park", "cupertino"], "us", "US"),
+ # Regions
+ (["emea"], "emea", "EMEA"),
+ (["americas", "north america", "latam"], "americas", "Americas"),
+ (["apac", "asia pacific", "asia-pacific"], "apac", "APAC"),
+ (["worldwide", "global", "anywhere", "earth"], "worldwide", "Worldwide"),
]
-def is_location_relevant(location: str, remote_type: str) -> bool:
+def extract_location_info(location: str, remote_type: str) -> tuple[list[str], str]:
"""
- Strict location filter. Only keeps jobs available in Canada, Germany, EMEA, or Worldwide.
- Filters out US-only jobs, UK jobs, APAC jobs, etc.
+ Extract location tags and short display text from a job's location.
+ Returns (list of tag ids, short display location)
"""
- if not location:
- return False # No location info = probably US-based, filter out
-
- loc_lower = location.lower()
-
- # Check if any desired region is mentioned FIRST
- has_desired = any(region in loc_lower for region in DESIRED_REGIONS)
-
- # If it has a desired region, keep it (even if it also mentions excluded locations)
- # e.g., "Remote (United States | Canada)" should be kept because of Canada
- if has_desired:
- return True
-
- # If it just says "Remote" with nothing else, keep it (truly remote)
- if loc_lower.strip() == "remote":
- return True
-
- # Check for excluded locations
- has_excluded = any(excl in loc_lower for excl in EXCLUDED_LOCATIONS)
- if has_excluded:
- return False
-
- # Check for patterns like "In-Office", "Hybrid", "On-site" without desired region
- if any(x in loc_lower for x in ["in-office", "hybrid", "on-site", "onsite", "office based"]):
- return False
-
- # If we can't determine, filter it out (safer)
- return False
-
-
-def extract_location_tags(location: str, remote_type: str) -> tuple[list[str], str]:
- """
- Extract relevant location tags and a short display location.
- Returns (list of tag names, short location string)
- """
- if not location:
- return [], ""
-
- loc_lower = location.lower()
tags = []
- short_loc = ""
+ display = ""
+
+ if not location:
+ return tags, display
+
+ loc_lower = location.lower()
# Check for remote
is_remote = remote_type == "remote" or "remote" in loc_lower
if is_remote:
tags.append("remote")
- # Check for Canada
- if any(x in loc_lower for x in ["canada", "toronto", "vancouver"]):
- tags.append("canada")
- short_loc = "Canada"
+ # Check against location rules
+ for keywords, tag_id, display_name in LOCATION_RULES:
+ if any(kw in loc_lower for kw in keywords):
+ if tag_id not in tags:
+ tags.append(tag_id)
+ if not display:
+ display = display_name
- # Check for Germany/Berlin
- if any(x in loc_lower for x in ["germany", "berlin", "munich"]):
- tags.append("germany")
- short_loc = "Germany" if "germany" in loc_lower else "Berlin"
+ # Fallback display
+ if not display:
+ if is_remote:
+ display = "Remote"
+ elif location:
+ display = location[:25] + "..." if len(location) > 25 else location
- # Check for EMEA
- if "emea" in loc_lower:
- tags.append("emea")
- short_loc = "EMEA"
-
- # Check for Americas/North America
- if "americas" in loc_lower or "north america" in loc_lower:
- tags.append("americas")
- short_loc = "Americas"
-
- # Check for Worldwide
- if any(x in loc_lower for x in ["worldwide", "global", "anywhere"]):
- tags.append("worldwide")
- short_loc = "Worldwide"
-
- # If no specific region found but it's remote
- if not short_loc and is_remote:
- short_loc = "Remote"
-
- return tags, short_loc
+ return tags, display
def generate_dashboard(output_path: str = "data/dashboard.html"):
"""Generate a static HTML dashboard."""
db = Database()
jobs = db.get_all_active_jobs()
-
- # Get all monitored companies
all_company_names = db.get_all_companies()
- # Track total jobs per company (before location filtering)
- total_per_company = {}
- for company_name, job in jobs:
- total_per_company[company_name] = total_per_company.get(company_name, 0) + 1
-
- # Group by company, filtering out irrelevant remote locations
+ # Process all jobs and collect location data
companies = {}
- filtered_count = 0
+ location_counts = Counter()
+
for company_name, job in jobs:
- if not is_location_relevant(job.location, job.remote_type):
- filtered_count += 1
- continue
+ # Extract location info
+ tags, display = extract_location_info(job.location, job.remote_type)
+
+ # Count locations for filter generation
+ for tag in tags:
+ location_counts[tag] += 1
+
+ # Store processed job data
if company_name not in companies:
companies[company_name] = []
- companies[company_name].append(job)
- # Ensure all monitored companies are in the dict (even with 0 jobs)
+ companies[company_name].append({
+ "job": job,
+ "tags": tags,
+ "display": display,
+ "search_text": f"{job.title.lower()} {(job.location or '').lower()} {(job.department or '').lower()} {' '.join(tags)}"
+ })
+
+ # Ensure all companies exist (even with 0 jobs)
for name in all_company_names:
if name not in companies:
companies[name] = []
- if name not in total_per_company:
- total_per_company[name] = 0
- total_shown = sum(len(jobs) for jobs in companies.values())
- total_scraped = sum(total_per_company.values())
-
- # Sort companies by name
+ total_jobs = sum(len(j) for j in companies.values())
sorted_companies = sorted(companies.items())
+ # Generate dynamic location filters (only show locations that exist in data)
+ # Order: Remote first, then by count descending
+ location_filters = []
+ if "remote" in location_counts:
+ location_filters.append(("remote", "Remote", location_counts["remote"]))
+
+ # Add other locations sorted by count
+ other_locations = [(tag, count) for tag, count in location_counts.items() if tag != "remote"]
+ other_locations.sort(key=lambda x: -x[1])
+
+ # Map tag_id to display name
+ tag_display = {tag_id: display for keywords, tag_id, display in LOCATION_RULES}
+ tag_display["remote"] = "Remote"
+
+ for tag_id, count in other_locations:
+ display = tag_display.get(tag_id, tag_id.title())
+ location_filters.append((tag_id, display, count))
+
+ # Generate location filter buttons HTML
+ location_buttons = ""
+ for tag_id, display, count in location_filters:
+ location_buttons += f' \n'
+
+ # Generate tag colors dynamically
+ tag_colors = {
+ "remote": ("#1a4a1a", "#4ade80"),
+ "canada": ("#4a1a1a", "#f87171"),
+ "germany": ("#4a4a1a", "#facc15"),
+ "uk": ("#2a1a3a", "#a78bfa"),
+ "us": ("#3a2a1a", "#fb923c"),
+ "emea": ("#1a3a4a", "#60a5fa"),
+ "americas": ("#3a1a4a", "#c084fc"),
+ "worldwide": ("#1a4a3a", "#34d399"),
+ "apac": ("#1a2a4a", "#38bdf8"),
+ "ireland": ("#1a4a2a", "#4ade80"),
+ "netherlands": ("#3a3a1a", "#fbbf24"),
+ "france": ("#2a2a4a", "#818cf8"),
+ "spain": ("#4a2a1a", "#fb7185"),
+ "poland": ("#3a1a2a", "#f472b6"),
+ "sweden": ("#1a3a3a", "#2dd4bf"),
+ "switzerland": ("#4a1a2a", "#fb7185"),
+ "australia": ("#2a3a1a", "#a3e635"),
+ "india": ("#4a3a1a", "#fcd34d"),
+ "japan": ("#4a1a3a", "#e879f9"),
+ "singapore": ("#1a4a4a", "#22d3d1"),
+ "israel": ("#3a2a2a", "#fca5a5"),
+ "brazil": ("#2a4a1a", "#86efac"),
+ }
+
+ # Generate CSS for tags
+ tag_css = ""
+ for tag_id, (bg, fg) in tag_colors.items():
+ tag_css += f""" .tag-{tag_id} {{
+ background: {bg};
+ color: {fg};
+ }}
+"""
+
html = f"""
@@ -299,30 +324,7 @@ def generate_dashboard(output_path: str = "data/dashboard.html"):
font-size: 11px;
margin-left: 5px;
}}
- .tag-remote {{
- background: #1a4a1a;
- color: #4ade80;
- }}
- .tag-canada {{
- background: #4a1a1a;
- color: #f87171;
- }}
- .tag-berlin {{
- background: #4a4a1a;
- color: #facc15;
- }}
- .tag-emea {{
- background: #1a3a4a;
- color: #60a5fa;
- }}
- .tag-americas {{
- background: #3a1a4a;
- color: #c084fc;
- }}
- .tag-worldwide {{
- background: #1a4a3a;
- color: #34d399;
- }}
+{tag_css}
.hidden {{
display: none;
}}
@@ -342,26 +344,32 @@ def generate_dashboard(output_path: str = "data/dashboard.html"):
flex-wrap: wrap;
gap: 10px;
}}
- .toc-links a {{
+ .toc-link {{
color: var(--accent);
text-decoration: none;
font-size: 13px;
}}
- .toc-links a:hover {{
+ .toc-link:hover {{
text-decoration: underline;
}}
- .toc-links .empty {{
+ .toc-link.empty {{
color: var(--muted);
- cursor: default;
}}
- .toc-links .empty:hover {{
- text-decoration: none;
+ .toc-link.hidden {{
+ display: none;
}}
- .filter-buttons {{
+ .filter-section {{
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
+ align-items: center;
+ }}
+ .filter-label {{
+ color: var(--muted);
+ font-size: 12px;
+ margin-right: 4px;
+ min-width: 60px;
}}
.filter-btn {{
background: var(--bg);
@@ -383,6 +391,13 @@ def generate_dashboard(output_path: str = "data/dashboard.html"):
border-color: var(--accent);
color: var(--bg);
}}
+ .clear-btn {{
+ border-color: #666;
+ }}
+ .clear-btn:hover {{
+ border-color: #f87171;
+ color: #f87171;
+ }}
@@ -390,47 +405,47 @@ def generate_dashboard(output_path: str = "data/dashboard.html"):
$ job-board
Last updated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} |
- {total_shown}/{total_scraped} jobs (location filtered) | Monitoring {len(all_company_names)} companies
+ {total_jobs} jobs | {len(all_company_names)} companies
-
-
Jump to company:
-
+
"""
- # Table of contents
+ # Table of contents with data attributes for JS updating
for company_name, company_jobs in sorted_companies:
- anchor = company_name.lower().replace(" ", "-")
- filtered = len(company_jobs)
- total = total_per_company.get(company_name, 0)
- if filtered > 0:
- html += f'
{company_name} ({filtered}/{total})\n'
- else:
- html += f'
{company_name} (0/{total})\n'
+ anchor = company_name.lower().replace(" ", "-").replace("'", "")
+ count = len(company_jobs)
+ css_class = "toc-link" if count > 0 else "toc-link empty"
+ html += f'
{company_name} ({count})\n'
html += """
@@ -438,44 +453,34 @@ def generate_dashboard(output_path: str = "data/dashboard.html"):
"""
- # Job listings (only for companies with jobs)
+ # Job listings
for company_name, company_jobs in sorted_companies:
if not company_jobs:
- continue # Skip companies with no jobs after filtering
- anchor = company_name.lower().replace(" ", "-")
+ continue
+ anchor = company_name.lower().replace(" ", "-").replace("'", "")
+ total = len(company_jobs)
html += f"""
-
+
"""
- for job in sorted(company_jobs, key=lambda j: j.title):
- location = job.location or ""
- location_lower = location.lower()
-
- # Extract tags and short location
- tag_list, short_loc = extract_location_tags(location, job.remote_type)
+ for job_data in sorted(company_jobs, key=lambda j: j["job"].title):
+ job = job_data["job"]
+ tags = job_data["tags"]
+ display = job_data["display"]
+ search_text = job_data["search_text"]
# Build tag HTML
- tags = ""
- if "remote" in tag_list:
- tags += '
remote'
- if "canada" in tag_list:
- tags += '
canada'
- if "germany" in tag_list:
- tags += '
germany'
- if "emea" in tag_list:
- tags += '
emea'
- if "americas" in tag_list:
- tags += '
americas'
- if "worldwide" in tag_list:
- tags += '
worldwide'
+ tag_html = ""
+ for tag in tags:
+ tag_html += f'
{tag}'
- html += f"""
-
{job.title}{tags}
-
{short_loc}
+ html += f"""
"""
html += """
@@ -488,67 +493,155 @@ def generate_dashboard(output_path: str = "data/dashboard.html"):
const search = document.getElementById('search');
const jobs = document.querySelectorAll('.job');
const companies = document.querySelectorAll('.company');
+ const tocLinks = document.querySelectorAll('.toc-link');
const visibleCount = document.getElementById('visible-count');
const filterBtns = document.querySelectorAll('.filter-btn');
+ const clearBtn = document.querySelector('.clear-btn');
- function filterJobs(query) {
- let visible = 0;
- const terms = query.toLowerCase().trim().split(/\\s+/).filter(t => t);
+ // Track active filters by category
+ const activeFilters = {
+ location: null,
+ role: null
+ };
+
+ function applyFilters() {
+ let totalVisible = 0;
+ const searchTerms = search.value.toLowerCase().trim().split(/\\s+/).filter(t => t);
+
+ // Build filter terms from active category filters
+ const locationTerms = activeFilters.location ? activeFilters.location.split(/\\s+/) : [];
+ const roleTerms = activeFilters.role ? activeFilters.role.split(/\\s+/) : [];
+
+ const hasFilters = searchTerms.length > 0 || locationTerms.length > 0 || roleTerms.length > 0;
+
+ // Track visible counts per company
+ const companyCounts = {};
companies.forEach(company => {
+ const companyId = company.dataset.company;
const companyJobs = company.querySelectorAll('.job');
let companyVisible = 0;
companyJobs.forEach(job => {
const searchText = job.dataset.search;
- // Match if ANY term matches (OR logic for filter buttons)
- const matches = terms.length === 0 || terms.some(term => searchText.includes(term));
+
+ // Match logic: AND between categories, OR within each category
+ let matches = true;
+
+ // Search box (OR within terms)
+ if (searchTerms.length > 0) {
+ matches = matches && searchTerms.some(term => searchText.includes(term));
+ }
+
+ // Location filter (OR within terms)
+ if (locationTerms.length > 0) {
+ matches = matches && locationTerms.some(term => searchText.includes(term));
+ }
+
+ // Role filter (OR within terms)
+ if (roleTerms.length > 0) {
+ matches = matches && roleTerms.some(term => searchText.includes(term));
+ }
+
job.classList.toggle('hidden', !matches);
if (matches) {
companyVisible++;
- visible++;
+ totalVisible++;
}
});
company.classList.toggle('hidden', companyVisible === 0);
+ companyCounts[companyId] = companyVisible;
+
+ // Update company header count
+ const countSpan = company.querySelector('.company-count');
+ const total = parseInt(countSpan.dataset.total);
+ if (!hasFilters) {
+ countSpan.textContent = `${total} positions`;
+ } else {
+ countSpan.textContent = `${companyVisible}/${total} positions`;
+ }
});
- visibleCount.textContent = `${visible} jobs shown`;
+ // Update TOC links - always show all, grey out empty ones
+ tocLinks.forEach(link => {
+ const companyId = link.dataset.company;
+ const total = parseInt(link.dataset.total);
+ const visible = companyCounts[companyId] || 0;
+ const name = link.textContent.replace(/\\s*\\(.*\\)/, '');
+
+ if (!hasFilters) {
+ link.textContent = `${name} (${total})`;
+ link.classList.toggle('empty', total === 0);
+ } else {
+ link.textContent = `${name} (${visible}/${total})`;
+ link.classList.toggle('empty', visible === 0);
+ }
+ // Always show the link, never hide
+ link.classList.remove('hidden');
+ });
+
+ visibleCount.textContent = `${totalVisible} jobs shown`;
}
- search.addEventListener('input', (e) => {
- // Clear active button when typing
+ function clearAllFilters() {
+ search.value = '';
+ activeFilters.location = null;
+ activeFilters.role = null;
filterBtns.forEach(btn => btn.classList.remove('active'));
- filterJobs(e.target.value);
+ applyFilters();
+ }
+
+ search.addEventListener('input', () => {
+ applyFilters();
});
- // Filter buttons
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
const filter = btn.dataset.filter;
- search.value = filter;
- filterBtns.forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
- filterJobs(filter);
+ const category = btn.dataset.category;
+ const action = btn.dataset.action;
+
+ // Handle clear button
+ if (action === 'clear') {
+ clearAllFilters();
+ return;
+ }
+
+ // Handle "All" button
+ if (category === 'all') {
+ clearAllFilters();
+ return;
+ }
+
+ // Toggle filter in category
+ const categoryBtns = document.querySelectorAll(`.filter-btn[data-category="${category}"]`);
+
+ if (btn.classList.contains('active')) {
+ // Deselect
+ btn.classList.remove('active');
+ activeFilters[category] = null;
+ } else {
+ // Select (deselect others in same category)
+ categoryBtns.forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ activeFilters[category] = filter;
+ }
+
+ applyFilters();
});
});
- // Keyboard shortcut: / to focus search
document.addEventListener('keydown', (e) => {
if (e.key === '/' && document.activeElement !== search) {
e.preventDefault();
search.focus();
}
if (e.key === 'Escape') {
- search.value = '';
- filterBtns.forEach(b => b.classList.remove('active'));
- filterJobs('');
+ clearAllFilters();
search.blur();
}
});
-
- // Set "All" as active by default
- filterBtns[0].classList.add('active');
diff --git a/data/dashboard.html b/data/dashboard.html
index eb6d900..b06bfca 100644
--- a/data/dashboard.html
+++ b/data/dashboard.html
@@ -140,10 +140,18 @@
background: #4a1a1a;
color: #f87171;
}
- .tag-berlin {
+ .tag-germany {
background: #4a4a1a;
color: #facc15;
}
+ .tag-uk {
+ background: #2a1a3a;
+ color: #a78bfa;
+ }
+ .tag-us {
+ background: #3a2a1a;
+ color: #fb923c;
+ }
.tag-emea {
background: #1a3a4a;
color: #60a5fa;
@@ -156,6 +164,63 @@
background: #1a4a3a;
color: #34d399;
}
+ .tag-apac {
+ background: #1a2a4a;
+ color: #38bdf8;
+ }
+ .tag-ireland {
+ background: #1a4a2a;
+ color: #4ade80;
+ }
+ .tag-netherlands {
+ background: #3a3a1a;
+ color: #fbbf24;
+ }
+ .tag-france {
+ background: #2a2a4a;
+ color: #818cf8;
+ }
+ .tag-spain {
+ background: #4a2a1a;
+ color: #fb7185;
+ }
+ .tag-poland {
+ background: #3a1a2a;
+ color: #f472b6;
+ }
+ .tag-sweden {
+ background: #1a3a3a;
+ color: #2dd4bf;
+ }
+ .tag-switzerland {
+ background: #4a1a2a;
+ color: #fb7185;
+ }
+ .tag-australia {
+ background: #2a3a1a;
+ color: #a3e635;
+ }
+ .tag-india {
+ background: #4a3a1a;
+ color: #fcd34d;
+ }
+ .tag-japan {
+ background: #4a1a3a;
+ color: #e879f9;
+ }
+ .tag-singapore {
+ background: #1a4a4a;
+ color: #22d3d1;
+ }
+ .tag-israel {
+ background: #3a2a2a;
+ color: #fca5a5;
+ }
+ .tag-brazil {
+ background: #2a4a1a;
+ color: #86efac;
+ }
+
.hidden {
display: none;
}
@@ -175,26 +240,32 @@
flex-wrap: wrap;
gap: 10px;
}
- .toc-links a {
+ .toc-link {
color: var(--accent);
text-decoration: none;
font-size: 13px;
}
- .toc-links a:hover {
+ .toc-link:hover {
text-decoration: underline;
}
- .toc-links .empty {
+ .toc-link.empty {
color: var(--muted);
- cursor: default;
}
- .toc-links .empty:hover {
- text-decoration: none;
+ .toc-link.hidden {
+ display: none;
}
- .filter-buttons {
+ .filter-section {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
+ align-items: center;
+ }
+ .filter-label {
+ color: var(--muted);
+ font-size: 12px;
+ margin-right: 4px;
+ min-width: 60px;
}
.filter-btn {
background: var(--bg);
@@ -216,175 +287,238 @@
border-color: var(--accent);
color: var(--bg);
}
+ .clear-btn {
+ border-color: #666;
+ }
+ .clear-btn:hover {
+ border-color: #f87171;
+ color: #f87171;
+ }
-
-
Jump to company:
-
-
+
-
-
Build Engineer remotecanada
+
-
-
Developer, Backendremotecanada
+
-
-
Developer, Enterprise Readyremotecanada
+
-
-
Director, Product Management - Platformsremotecanada
+
-
-
Director, Security Researchremotecanada
+
+
-
-
Engineering Manager, Product Engineeringremotecanada
+
-
-
Manager, Security Incident Responseremotecanada
+
-
-
Principal Developer, AI & Developer Teamremotecanada
+
-
-
Principal Product Marketing Manager, Developerremotecanada
+
+
-
-
Security Engineer, Corporate Securityremotecanada
+
-
-
Senior Developer, Data Securityremotecanada
+
-
-
Senior Director, Developer Platformremotecanada
+
+
-
-
Senior IT Engineerremotecanada
+
-
-
Senior Security Engineer, Application Securityremotecanada
+
-
-
Senior Security Engineer, Detection and Responseremotecanada
+
-
-
Senior Security Engineer, GRC Automationremotecanada
+
-
-
Senior Security Engineer, Threat Intelligenceremotecanada
+
-
-
Solutions Engineer, Commercialremotecanada
+
-
-
Staff Developer, Authentication Enablementremotecanada
+
+
-
-
+
-
+
-
-
Alliances Field Engineerworldwide
-
Worldwide
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
Engineering Manager, DocSendremotecanada
+
+
+
+
+
-
-
Fullstack Product Software Engineer, Core Web Experienceremotecanada
-
Canada
+
+
-
-
Infrastructure Software Engineer, Metadata remotecanada
+
+
+
+
+
+
+
+
+
-
-
-
Principal Software Engineer, Developer Productivityremotecanada
-
Canada
+
-
-
Product Backend Software Engineer, Search Platformremotecanada
-
Canada
+
-
-
Senior Android Software Engineer, Mobile Experienceremotecanada
-
Canada
+
-
-
Senior Backend Product Software Engineer, Reclaimremotecanada
-
Canada
+
+
+
+
+
+
-
+
+
+
+
-
-
Senior Machine Learning Engineer, Dash remotecanada
-
Canada
+
+
+
+
+
+
+
+
+
+
+
-
-
Staff Backend Product Software Engineer, Core Syncremotecanada
+
-
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
Intermediate Backend Engineer (Ruby on Rails), Analytics Instrumentation remotecanadaemea
-
EMEA
+
-
-
Intermediate Fullstack Engineer (TypeScript), AI Engineering: Editor Extensions โ Multi-Platformremotecanada
+
@@ -1345,7 +3551,7 @@
-
-
-
Principal Engineer, Software Supply Chain Securityremotecanada
+
-
-
Principal Infrastructure Security Architectremotecanadaemea
-
EMEA
+
-
-
Principal Product Manager, Security & Complianceremotecanada
+
+
-
-
Principal Security Engineer, Application Security remoteemeaamericas
-
Americas
+
-
-
Senior Backend Engineer (Ruby on Rails), Verify: Pipeline Executionremotecanada
+
+
+
-
-
Senior Backend Engineer(Golang),Software Supply Chain Security: Auth Infrastructureremotecanadaamericas
-
Americas
+
-
-
Senior Fullstack Engineer (RoR/vue.js), Software Supply Chain Security: Authorizationremotecanada
+
-
-
Senior Infrastructure Security Engineer remoteemea
-
EMEA
+
-
-
+
+
-
-
Senior AI Engineer - Grafana Ops, AI/ML | Canada | Remoteremotecanada
-
Canada
+
-
-
Senior AI Engineer, GenAI & ML Evaluation Frameworks - Grafana Ops, AI/ML | Canada | Remote remotecanada
-
Canada
+
+
-
-
Senior Backend Software Engineer, Alerting | Canada | Remoteremotecanada
-
Canada
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
+
-
-
(Senior) Backend Developer (Java/Kotlin) - Business Application Developmentremotegermany
+
-
-
Engineering Squad Lead (JetBrains Research)remotegermany
+
-
-
FullStack ML Developerremotegermany
+
-
-
Head of Corporate Securitygermany
+
+
-
-
Infrastructure Security Engineerremotegermany
+
-
-
Java Developer (Backend) โ TeamCity, Build Tools Integrationgermany
+
-
-
JetBrains Go Developer Advocate (Developer Advocacy)remotegermany
+
-
-
JetBrains Rust Developer Advocategermany
+
-
-
JetBrains Web Developer Advocate (Developer Advocacy)remotegermany
+
+
-
-
Kotlin Developer Advocategermany
+
-
-
Machine Learning Evaluation Engineer (Agentic Mobile App Generator)germany
+
-
-
Project Maintainer โ DPAI Arena Evaluation Infrastructureremotegermany
+
-
-
Project Manager (Software Engineering Research)germany
+
-
-
QA Engineer (Kotlin Build Tools)germany
+
-
-
QA Engineer (Kotlin Compiler Frontend)germany
+
-
-
QA Engineer (Rider IDE)germany
+
-
-
Quality Engineer (IDEs department) germany
+
+
-
-
Research Engineer (Agentic Models)remotegermany
+
-
-
Research Engineer (LLM Training and Performance)germany
+
-
-
Research Engineer - JetBrains AIremotegermany
+
-
-
SDET Engineer in KED QA Automation teamgermany
+
-
-
SDET Engineer in Kotlin Performance QA teamgermany
+
-
-
SSH & Remote Development Engineergermany
+
-
-
-
Security Engineer in Product Securityremotegermany
+
-
-
Senior Developer at Rider (GameDev Tools) remotegermany
+
-
-
Senior ML Engineer (JetBrains Research)remotegermany
+
-
-
Senior Machine Learning Engineer (IntelliJ AI)germany
+
-
-
Senior Product Manager (AI BI Platform)germany
+
-
-
Senior Product Manager (IntelliJ Platform)germany
+
-
-
Senior QA engineer (BAD)remotegermany
+
-
-
Senior Software Developer (IntelliJ AI)germany
+
-
-
Senior Software Developer (Qodana Core)remotegermany
+
-
-
Senior Software Developer (Quality Infrastructure) germany
+
-
-
Senior Software Developer (Rider)remotegermany
+
-
-
Senior Software Developer - Kotlin Nativeremotegermany
+
-
-
Senior Software Engineer (.NET tooling Core)remotegermany
+
-
-
Senior Software Engineer โ IntelliJ Ultimate Teamgermany
+
-
-
Senior/Staff Software Developer - Kotlin Multiplatform Toolingremotegermany
+
-
-
Software Developer (IntelliJ Platform โ Version Control Experience)germany
+
-
-
Software Developer (IntelliJ Platform)remotegermany
+
-
-
Software Developer (Orca)germany
+
-
-
Software Developer (Platform/ Remote Development)germany
+
-
-
Software Developer (Station/Toolbox App)germany
+
-
-
Software Development Engineer in Test (Rider)germany
+
-
-
Software Engineer (IntelliJ Platfrom Licensing)germany
+
-
-
Staff Product Manager (High Performance Data Infrastructure)germany
+
-
-
Staff Software Developer (Kotlin Libraries)remotegermany
+
-
-
Support Engineer (Business Applications Development)remotegermany
+
-
-
Support Engineer (IDE Services)remotegermany
+
-
-
Support Engineer (JetBrains Academy)germany
+
-
-
Support Engineer (JetBrains Console)germany
+
+
+
+
+
+
-
-
+
+
+
-
+
-
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
-
Account Executive, Platforms (German-speaking)germany
-
Berlin
+
-
-
Backend Engineer, DEePremotecanada
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
Backend Engineer, Link canada
-
Canada
+
+
+
+
+
-
-
Client Onboarding Integration Engineercanada
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
Security Engineer, New Grad canada
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
Senior Software Engineer, AI Tools โ Tiger Labsremote
Remote
@@ -2146,67 +5302,155 @@
const search = document.getElementById('search');
const jobs = document.querySelectorAll('.job');
const companies = document.querySelectorAll('.company');
+ const tocLinks = document.querySelectorAll('.toc-link');
const visibleCount = document.getElementById('visible-count');
const filterBtns = document.querySelectorAll('.filter-btn');
+ const clearBtn = document.querySelector('.clear-btn');
- function filterJobs(query) {
- let visible = 0;
- const terms = query.toLowerCase().trim().split(/\s+/).filter(t => t);
+ // Track active filters by category
+ const activeFilters = {
+ location: null,
+ role: null
+ };
+
+ function applyFilters() {
+ let totalVisible = 0;
+ const searchTerms = search.value.toLowerCase().trim().split(/\s+/).filter(t => t);
+
+ // Build filter terms from active category filters
+ const locationTerms = activeFilters.location ? activeFilters.location.split(/\s+/) : [];
+ const roleTerms = activeFilters.role ? activeFilters.role.split(/\s+/) : [];
+
+ const hasFilters = searchTerms.length > 0 || locationTerms.length > 0 || roleTerms.length > 0;
+
+ // Track visible counts per company
+ const companyCounts = {};
companies.forEach(company => {
+ const companyId = company.dataset.company;
const companyJobs = company.querySelectorAll('.job');
let companyVisible = 0;
companyJobs.forEach(job => {
const searchText = job.dataset.search;
- // Match if ANY term matches (OR logic for filter buttons)
- const matches = terms.length === 0 || terms.some(term => searchText.includes(term));
+
+ // Match logic: AND between categories, OR within each category
+ let matches = true;
+
+ // Search box (OR within terms)
+ if (searchTerms.length > 0) {
+ matches = matches && searchTerms.some(term => searchText.includes(term));
+ }
+
+ // Location filter (OR within terms)
+ if (locationTerms.length > 0) {
+ matches = matches && locationTerms.some(term => searchText.includes(term));
+ }
+
+ // Role filter (OR within terms)
+ if (roleTerms.length > 0) {
+ matches = matches && roleTerms.some(term => searchText.includes(term));
+ }
+
job.classList.toggle('hidden', !matches);
if (matches) {
companyVisible++;
- visible++;
+ totalVisible++;
}
});
company.classList.toggle('hidden', companyVisible === 0);
+ companyCounts[companyId] = companyVisible;
+
+ // Update company header count
+ const countSpan = company.querySelector('.company-count');
+ const total = parseInt(countSpan.dataset.total);
+ if (!hasFilters) {
+ countSpan.textContent = `${total} positions`;
+ } else {
+ countSpan.textContent = `${companyVisible}/${total} positions`;
+ }
});
- visibleCount.textContent = `${visible} jobs shown`;
+ // Update TOC links - always show all, grey out empty ones
+ tocLinks.forEach(link => {
+ const companyId = link.dataset.company;
+ const total = parseInt(link.dataset.total);
+ const visible = companyCounts[companyId] || 0;
+ const name = link.textContent.replace(/\s*\(.*\)/, '');
+
+ if (!hasFilters) {
+ link.textContent = `${name} (${total})`;
+ link.classList.toggle('empty', total === 0);
+ } else {
+ link.textContent = `${name} (${visible}/${total})`;
+ link.classList.toggle('empty', visible === 0);
+ }
+ // Always show the link, never hide
+ link.classList.remove('hidden');
+ });
+
+ visibleCount.textContent = `${totalVisible} jobs shown`;
}
- search.addEventListener('input', (e) => {
- // Clear active button when typing
+ function clearAllFilters() {
+ search.value = '';
+ activeFilters.location = null;
+ activeFilters.role = null;
filterBtns.forEach(btn => btn.classList.remove('active'));
- filterJobs(e.target.value);
+ applyFilters();
+ }
+
+ search.addEventListener('input', () => {
+ applyFilters();
});
- // Filter buttons
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
const filter = btn.dataset.filter;
- search.value = filter;
- filterBtns.forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
- filterJobs(filter);
+ const category = btn.dataset.category;
+ const action = btn.dataset.action;
+
+ // Handle clear button
+ if (action === 'clear') {
+ clearAllFilters();
+ return;
+ }
+
+ // Handle "All" button
+ if (category === 'all') {
+ clearAllFilters();
+ return;
+ }
+
+ // Toggle filter in category
+ const categoryBtns = document.querySelectorAll(`.filter-btn[data-category="${category}"]`);
+
+ if (btn.classList.contains('active')) {
+ // Deselect
+ btn.classList.remove('active');
+ activeFilters[category] = null;
+ } else {
+ // Select (deselect others in same category)
+ categoryBtns.forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ activeFilters[category] = filter;
+ }
+
+ applyFilters();
});
});
- // Keyboard shortcut: / to focus search
document.addEventListener('keydown', (e) => {
if (e.key === '/' && document.activeElement !== search) {
e.preventDefault();
search.focus();
}
if (e.key === 'Escape') {
- search.value = '';
- filterBtns.forEach(b => b.classList.remove('active'));
- filterJobs('');
+ clearAllFilters();
search.blur();
}
});
-
- // Set "All" as active by default
- filterBtns[0].classList.add('active');