Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/fortune_intel/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ def jobs(
tier: str = Query(default="", pattern="^[A-Ea-e]?$"),
opened_within_days: int = Query(default=0, ge=0, le=365),
verified_within_hours: int = Query(default=0, ge=0, le=168),
sort: str = Query(default="newest", pattern="^(newest|verified)$"),
status: str = Query(default="active", pattern="^(active|closed)$"),
limit: int = Query(default=50, ge=1, le=50),
offset: int = Query(default=0, ge=0, le=5000),
Expand All @@ -191,15 +192,17 @@ def jobs(
"tier": tier,
"opened_within_days": opened_within_days,
"verified_within_hours": verified_within_hours,
"sort": sort,
"status": status,
"include_synthetic": settings.show_synthetic,
"us_eligibility": "eligible",
}
records = repository.list_jobs(**filters, limit=limit, offset=offset)
count_filters = {key: value for key, value in filters.items() if key != "sort"}
return {
"items": records,
"count": len(records),
"total": repository.count_jobs(**filters),
"total": repository.count_jobs(**count_filters),
"limit": limit,
"offset": offset,
"country_scope": COUNTRY_SCOPE,
Expand Down
13 changes: 11 additions & 2 deletions src/fortune_intel/storage/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ def list_jobs(
tier: str = "",
opened_within_days: int = 0,
verified_within_hours: int = 0,
sort: str = "newest",
status: str = "active",
limit: int = 50,
offset: int = 0,
Expand Down Expand Up @@ -282,6 +283,15 @@ def list_jobs(
"j.last_seen_at IS NOT NULL AND datetime(j.last_seen_at) >= datetime('now', ?)"
)
params.append(f"-{verified_within_hours} hours")
sort_order = {
"newest": "datetime(COALESCE(j.posted_at, j.first_seen_at)) DESC, j.title ASC",
"verified": (
"datetime(j.last_seen_at) DESC, "
"datetime(COALESCE(j.posted_at, j.first_seen_at)) DESC, j.title ASC"
),
}.get(sort)
if sort_order is None:
raise ValueError("sort must be newest or verified")
params.extend([min(max(limit, 1), 200), max(offset, 0)])
with self.connect() as connection:
rows = connection.execute(
Expand All @@ -299,8 +309,7 @@ def list_jobs(
c.name AS company_name, c.slug AS company_slug, c.ats_type
FROM jobs j JOIN companies c ON c.id = j.company_id
WHERE {" AND ".join(clauses)}
ORDER BY datetime(COALESCE(j.posted_at, j.first_seen_at)) DESC,
j.title ASC
ORDER BY {sort_order}
LIMIT ? OFFSET ?
""",
params,
Expand Down
2 changes: 1 addition & 1 deletion src/fortune_intel/web/api.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ <h1>Build from evidence.</h1>
<p class="lead">Search verified U.S. openings, inspect H-1B evidence tiers, and monitor source freshness. Responses are JSON, bounded, paginated, and rate limited.</p>
<section>
<h2>Endpoints</h2>
<div class="endpoint"><code>GET /api/jobs</code><p>Search definite U.S. roles with <code>q</code>, <code>company</code>, <code>location</code>, <code>tier</code>, <code>opened_within_days</code>, <code>verified_within_hours</code>, <code>status</code>, <code>limit</code>, and <code>offset</code>. The opening-date filter uses employer-posted dates only; roles without a source date are excluded. The verification filter uses the most recent time OpenRole observed that individual role. Ambiguous and non-U.S. locations are never returned.</p></div>
<div class="endpoint"><code>GET /api/jobs</code><p>Search definite U.S. roles with <code>q</code>, <code>company</code>, <code>location</code>, <code>tier</code>, <code>opened_within_days</code>, <code>verified_within_hours</code>, <code>sort</code>, <code>status</code>, <code>limit</code>, and <code>offset</code>. Use <code>sort=newest</code> for employer posting recency or <code>sort=verified</code> for OpenRole verification recency. The opening-date filter uses employer-posted dates only; roles without a source date are excluded. The verification filter uses the most recent time OpenRole observed that individual role. Ambiguous and non-U.S. locations are never returned.</p></div>
<div class="endpoint"><code>GET /api/jobs/{id}</code><p>Read a privacy-limited description excerpt and the evidence used for one assessment.</p></div>
<div class="endpoint"><code>GET /api/companies</code><p>Search public company identities with <code>q</code>, <code>limit</code>, and <code>offset</code>; inspect active-job coverage and freshness.</p></div>
<div class="endpoint"><code>GET /api/coverage</code><p>Inspect the complete company denominator and auditable discovery, approval, and successful-source counts. Directory inclusion alone is not coverage.</p></div>
Expand Down
9 changes: 8 additions & 1 deletion src/fortune_intel/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ function replaceSearchUrl(company, filters) {
if (filters.verifiedWithin) {
params.set("verified_within_hours", filters.verifiedWithin);
}
if (filters.sort !== "newest") params.set("sort", filters.sort);
const search = params.toString();
history.replaceState(null, "", search ? `/?${search}#jobs` : "/");
}
Expand All @@ -139,6 +140,10 @@ function loadSearchInputs() {
if (["", "6", "24", "72"].includes(verifiedWithin)) {
document.querySelector("#verified-within").value = verifiedWithin;
}
const sort = params.get("sort") || "newest";
if (["newest", "verified"].includes(sort)) {
document.querySelector("#sort").value = sort;
}
return params.get("company") || "";
}

Expand All @@ -150,14 +155,16 @@ async function loadJobs(company = "") {
const tier = document.querySelector("#tier").value;
const openedWithin = document.querySelector("#opened-within").value;
const verifiedWithin = document.querySelector("#verified-within").value;
const filters = { query, location, tier, openedWithin, verifiedWithin };
const sort = document.querySelector("#sort").value;
const filters = { query, location, tier, openedWithin, verifiedWithin, sort };
replaceSearchUrl(company, filters);
if (query) params.set("q", query);
if (company) params.set("company", company);
if (location) params.set("location", location);
if (tier) params.set("tier", tier);
if (openedWithin) params.set("opened_within_days", openedWithin);
if (verifiedWithin) params.set("verified_within_hours", verifiedWithin);
if (sort !== "newest") params.set("sort", sort);

const list = document.querySelector("#job-list");
const empty = document.querySelector("#empty-state");
Expand Down
11 changes: 9 additions & 2 deletions src/fortune_intel/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<meta name="description" content="Explainable job and H-1B sponsorship intelligence." />
<title>OpenRole Intelligence</title>
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/assets/styles.css?v=9" />
<link rel="stylesheet" href="/assets/styles.css?v=10" />
</head>
<body>
<header class="site-header">
Expand Down Expand Up @@ -70,6 +70,13 @@ <h1>Find roles with a clearer<br /><em>sponsorship signal.</em></h1>
<option value="72">Last 3 days</option>
</select>
</label>
<label>
<span>Sort</span>
<select id="sort" name="sort">
<option value="newest">Latest posting</option>
<option value="verified">Recently verified</option>
</select>
</label>
<div class="search-actions">
<button type="submit">Explore roles <span>→</span></button>
<button id="clear-filters" class="clear-filters" type="button">Clear</button>
Expand Down Expand Up @@ -127,6 +134,6 @@ <h2>A signal you can inspect.</h2>
<span>Evidence-first job intelligence</span>
<span><a href="/trust">Trust &amp; methodology</a> · Built for transparency, freshness, and correction.</span>
</footer>
<script src="/assets/app.js?v=10" defer></script>
<script src="/assets/app.js?v=11" defer></script>
</body>
</html>
8 changes: 7 additions & 1 deletion src/fortune_intel/web/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ h1 { margin-top: 20px; font-size: clamp(43px, 6vw, 76px); line-height: 1.04; fon
h1 em { color: var(--green); font-style: normal; }
.hero > p { max-width: 680px; margin: 24px auto 0; color: var(--muted); font-size: 17px; line-height: 1.65; }

.search-panel { max-width: 1400px; margin: 40px auto 0; padding: 9px; display: grid; grid-template-columns: 1.25fr 1fr 1fr .9fr .9fr auto; gap: 0; background: var(--card); border: 1px solid var(--line); border-radius: 15px; box-shadow: 0 20px 60px rgba(32,48,39,.11); text-align: left; }
.search-panel { max-width: 1500px; margin: 40px auto 0; padding: 9px; display: grid; grid-template-columns: 1.15fr 1fr 1fr .9fr .9fr .8fr auto; gap: 0; background: var(--card); border: 1px solid var(--line); border-radius: 15px; box-shadow: 0 20px 60px rgba(32,48,39,.11); text-align: left; }
.search-panel label { padding: 8px 18px; border-right: 1px solid var(--line); }
.search-panel label span { display: block; margin-bottom: 5px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: #808a85; }
.search-panel input, .search-panel select { width: 100%; border: 0; outline: 0; background: transparent; color: var(--ink); }
Expand Down Expand Up @@ -150,6 +150,12 @@ td { font-size: 14px; }
.pagination button { min-height: 40px; }
.pagination button:disabled { opacity: .4; cursor: not-allowed; }

@media (max-width: 1200px) {
.search-panel { max-width: 920px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.search-panel label:nth-of-type(3n) { border-right: 0; }
.search-actions { grid-column: 1 / -1; grid-template-columns: 1fr auto; min-height: 46px; }
}

@media (max-width: 850px) {
nav { display: none; }
.search-panel { grid-template-columns: 1fr; }
Expand Down
42 changes: 40 additions & 2 deletions tests/test_public_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,42 @@ def test_job_api_can_filter_by_the_last_time_a_role_was_verified(tmp_path):
assert client.get("/api/jobs", params={"verified_within_hours": 169}).status_code == 422


def test_job_api_can_sort_by_the_last_time_a_role_was_verified(tmp_path):
client, app = make_client(tmp_path)
company_id = app.state.repository.upsert_company("Verification Sort Check")
job_ids: dict[str, str] = {}
for external_id in ("old", "new"):
job_ids[external_id] = app.state.repository.upsert_job(
company_id,
JobRecord(
company_name="Verification Sort Check",
title=f"Verification sort {external_id}",
url=f"https://jobs.example.test/verification-sort/{external_id}",
source="verification-sort-test",
external_job_id=external_id,
location="Austin, TX",
),
assess_sponsorship(""),
)
with app.state.repository.connect() as connection:
connection.execute(
"UPDATE jobs SET last_seen_at = ? WHERE id = ?",
((datetime.now(UTC) - timedelta(hours=4)).isoformat(), job_ids["old"]),
)

response = client.get(
"/api/jobs",
params={"q": "Verification sort", "sort": "verified"},
)

assert response.status_code == 200
assert [item["title"] for item in response.json()["items"]] == [
"Verification sort new",
"Verification sort old",
]
assert client.get("/api/jobs", params={"sort": "oldest"}).status_code == 422


def test_public_readiness_uses_lightweight_checks(tmp_path, monkeypatch):
client, app = make_client(tmp_path)
called = {}
Expand Down Expand Up @@ -256,13 +292,15 @@ def test_dashboard_hidden_empty_state_cannot_display_with_results(tmp_path):
assert "[hidden] { display: none !important; }" in stylesheet
assert "empty.hidden = true;" in script
assert "empty.hidden = data.items.length !== 0;" in script
assert "/assets/styles.css?v=9" in dashboard
assert "/assets/styles.css?v=10" in dashboard
assert 'id="opened-within"' in dashboard
assert 'id="verified-within"' in dashboard
assert 'id="sort"' in dashboard
assert 'id="clear-filters"' in dashboard
assert "/assets/app.js?v=10" in dashboard
assert "/assets/app.js?v=11" in dashboard
assert 'params.set("opened_within_days", openedWithin);' in script
assert 'params.set("verified_within_hours", verifiedWithin);' in script
assert 'params.set("sort", sort);' in script
assert "function replaceSearchUrl(company, filters)" in script
assert "function loadSearchInputs()" in script

Expand Down
Loading