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
22 changes: 22 additions & 0 deletions backend/services/job_search_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ def search_cached(self, query: JobSearchQuery) -> JobSearchResult:
posted_within_days=query.posted_within_days,
page_size=max(1, min(int(query.page_size or 20), 50)),
offset=max(0, int(query.offset or 0)),
# Carry the dropdown / sort fields through. Earlier
# versions dropped them here, which silently disabled the
# Work-mode filter and the Sort dropdown — the chip /
# label would flip in the UI but the request never reached
# the RPC with the new value. Whitelisting + lowercasing
# still happens in CachedJobsStore.search() so the schema
# layer stays decoupled from RPC vocabulary.
work_modes=list(query.work_modes or []),
employment_types=list(query.employment_types or []),
sort_by=str(query.sort_by or "relevance").strip().lower() or "relevance",
)

store = self._get_cache_store()
Expand All @@ -123,6 +133,9 @@ def search_cached(self, query: JobSearchQuery) -> JobSearchResult:
posted_within_days=normalized_query.posted_within_days,
limit=normalized_query.page_size,
offset=normalized_query.offset,
work_modes=list(normalized_query.work_modes) or None,
employment_types=list(normalized_query.employment_types) or None,
sort_by=normalized_query.sort_by,
)
except Exception as exc: # noqa: BLE001 — cache outage shouldn't kill search
# Fall through to the live path. The cache is a perf
Expand Down Expand Up @@ -153,6 +166,15 @@ def search(self, query: JobSearchQuery) -> JobSearchResult:
posted_within_days=query.posted_within_days,
page_size=max(1, min(int(query.page_size or 20), 50)),
offset=max(0, int(query.offset or 0)),
# Preserve dropdown / sort fields. The live path can't push
# work_modes / employment_types into the upstream boards
# (their APIs don't accept those filters), but we keep the
# values on the query so any downstream in-memory sort or
# filter pass sees them — and so the echoed JobSearchQuery
# in the response matches what the UI sent.
work_modes=list(query.work_modes or []),
employment_types=list(query.employment_types or []),
sort_by=str(query.sort_by or "relevance").strip().lower() or "relevance",
)
requested_sources = {
str(item).strip().lower()
Expand Down
45 changes: 39 additions & 6 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -5464,18 +5464,33 @@ code {
z-index: 1;
}
/* Hover (only on enabled steps) — pill bg + accent ring + tiny lift.
The ring is the strongest "I'm clickable" cue; the lift adds depth. */
.b-rail-step:hover:not(:disabled) {
The ring is the strongest "I'm clickable" cue; the lift adds depth.
`[data-locked]` is excluded from the lift so the gated step doesn't
feel like a normal tab, but it still gets a faint hover tint via
the rule below so the user knows their click WILL do something
(route them to the missing prerequisite + show a notice). */
.b-rail-step:hover:not(:disabled):not([data-locked="true"]) {
color: var(--fg);
background: rgba(255, 255, 255, 0.05);
border-color: rgba(90, 134, 255, 0.30);
transform: translateY(-1px);
}
/* Gated — fade out the whole chip clearly so it doesn't look like an
un-clicked tab. Cursor + title attribute give the user a reason. */
.b-rail-step:disabled {
/* Gated — fade the chip clearly so it doesn't look like an un-clicked
tab. Cursor: help signals "tap to learn what's missing" — earlier
the chip was HTML-disabled with cursor: not-allowed, which made the
click a silent no-op; the new behavior navigates the user to the
missing prereq + surfaces a notice. */
.b-rail-step:disabled,
.b-rail-step[data-locked="true"] {
opacity: 0.40;
cursor: not-allowed;
cursor: help;
}
.b-rail-step[data-locked="true"]:hover {
/* Faint hover tint so the user gets a click-affordance cue without
the full "this is the active tab" treatment. Confirms the chip is
interactive even though it's gated. */
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.10);
}
.b-rail-step[aria-selected="true"] {
color: var(--fg);
Expand Down Expand Up @@ -5907,6 +5922,24 @@ code {
font-weight: 500;
margin-left: 2px;
}
/* "Run analysis to compute" / "Re-run analysis (inputs changed)" hint
shown under the Match-score tile before analysisState is fresh.
Small, dim, and italicized so it reads as a sublabel rather than
competing with the metric value. */
.b-jd-metric-hint {
margin-top: 2px;
font-size: 11.5px;
font-style: italic;
color: var(--fg-3);
line-height: 1.35;
}
/* Muted variant for placeholder tiles whose value is "—" — fades the
big number so the user understands it's not a real measurement
yet, without removing the column slot (which would cause layout
shift the moment analysis completes). */
.b-jd-metric[data-tone="muted"] .b-jd-metric-value {
color: var(--fg-3);
}

/* ── Two-up sections (Skills + Experience, Hard + Soft skills) ── */
.b-resume-twoup {
Expand Down
39 changes: 37 additions & 2 deletions frontend/src/components/workspace/JDReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,22 @@ export function JDReview({
// Hero metrics: prefer the parsed analysisState numbers when fresh,
// fall back to the JobReview computed by `buildJobReview` from the
// current manualJobText.
const metrics = (() => {
//
// The "Match score" tile is always rendered (even before analysis
// runs) with a `hint` placeholder. Earlier the score only appeared
// post-analysis, so the UI test reported "no match-score on Job
// Detail" — a user who pasted a JD but hadn't yet run Step 04 saw
// no fit signal at all and didn't know where it would surface.
// The placeholder gives a visible affordance + tells them what to
// do next without forcing a navigation away from this tab.
type HeroMetric = {
label: string;
value: string;
unit: string;
hint?: string;
tone?: "muted";
};
const metrics = ((): HeroMetric[] => {
if (analysisState && !analysisIsStale) {
const fit = analysisState.fit_analysis;
return [
Expand Down Expand Up @@ -211,6 +226,19 @@ export function JDReview({
}
if (review) {
return [
{
label: "Match score",
value: "—",
unit: "",
// Stale analysis lingering in state when the inputs have
// changed = a number that's no longer trustworthy. Tell the
// user the cause + remedy in the hint so we're not silently
// showing a wrong fit %.
hint: analysisState && analysisIsStale
? "Re-run analysis (inputs changed)"
: "Run analysis to compute",
tone: "muted",
},
{
label: "Hard skills",
value: String(review.hardSkills.length),
Expand Down Expand Up @@ -354,9 +382,16 @@ export function JDReview({
{metrics.length ? (
<div className="b-jd-metrics">
{metrics.map((metric) => (
<div className="b-jd-metric" key={metric.label}>
<div
className="b-jd-metric"
data-tone={metric.tone || undefined}
key={metric.label}
>
<div className="b-jd-metric-label">{metric.label}</div>
<JDMetricValue unit={metric.unit} value={metric.value} />
{metric.hint ? (
<div className="b-jd-metric-hint">{metric.hint}</div>
) : null}
</div>
))}
</div>
Expand Down
86 changes: 83 additions & 3 deletions frontend/src/components/workspace/WorkspaceShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,48 @@ function formatUtcTimestamp(value: string) {
function formatRemainingCalls(dailyQuota: DailyQuotaStatus | null) {
if (!dailyQuota) return "Unavailable";
if (dailyQuota.remaining_calls === null || dailyQuota.max_calls === null) {
// Unlimited tiers are internal/admin (no daily cost cap). Append
// the tier so the dev account is obviously identifiable in the
// popover and a paying user with `null` caps (e.g. an explicit
// "Unlimited" addon down the road) sees the same shape.
const tier = (dailyQuota.plan_tier || "").trim().toLowerCase();
if (tier === "internal" || tier === "admin") {
return `Unlimited (${formatTier(tier)})`;
}
return "Unlimited";
}
return `${dailyQuota.remaining_calls}/${dailyQuota.max_calls}`;
}

/**
* Capitalize a raw plan_tier string ("internal", "business", "pro",
* "free", "admin") for display. Backend stores these lowercase as
* enum-ish strings; the UI should render them as proper nouns.
* Falls back to the raw value (with first letter uppercased) for
* any tier we haven't enumerated explicitly, so a future "enterprise"
* tier still renders sanely without a code change.
*/
function formatTier(tier: string | null | undefined): string {
const normalized = (tier || "").trim().toLowerCase();
switch (normalized) {
case "free":
return "Free";
case "pro":
case "paid":
case "plus":
return "Pro";
case "business":
return "Business";
case "internal":
return "Internal";
case "admin":
return "Admin";
default:
if (!normalized) return "Free";
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
}
}

function getInitialMainTab(): WorkspaceMainTab {
if (typeof window === "undefined") return "resume";

Expand Down Expand Up @@ -2150,7 +2187,7 @@ export function WorkspaceShell() {
<dl className="b-account-pop-stats">
<div>
<dt>Plan</dt>
<dd>{authSession?.app_user.plan_tier || "free"}</dd>
<dd>{formatTier(authSession?.app_user.plan_tier)}</dd>
</div>
<div>
<dt>Runs left</dt>
Expand Down Expand Up @@ -2271,6 +2308,32 @@ export function WorkspaceShell() {
jd: "",
analysis: "Need a parsed resume + job description first.",
};
// When the user clicks a locked rail step we don't want a
// silent no-op — the UI was confusing as "is this broken?".
// Compute the first missing prerequisite for each gated step
// so the click handler can jump them there and surface a
// helpful notice. Today only Analysis is gated; keeping the
// shape per-step so future locks plug in the same way.
const lockedPrereqStep: Record<WorkspaceMainTab, WorkspaceMainTab | null> = {
resume: null,
jobs: null,
jd: null,
analysis: !resumeText.trim()
? "resume"
: !manualJobText.trim()
? "jd"
: null,
};
const lockedPrereqMessage: Record<WorkspaceMainTab, string> = {
resume: "",
jobs: "",
jd: "",
analysis: !resumeText.trim()
? "Upload a résumé in Step 01 to unlock Analysis."
: !manualJobText.trim()
? "Paste a job description in Step 03 to unlock Analysis."
: "Both inputs are loaded — Analysis is ready to run.",
};
return (
<div
className="b-rail"
Expand Down Expand Up @@ -2299,15 +2362,32 @@ export function WorkspaceShell() {
: `${meta.label} · click to open`;
return (
<button
aria-disabled={!ready || undefined}
aria-label={meta.label}
aria-selected={active}
className="b-rail-step"
data-done={done || undefined}
data-locked={!ready || undefined}
data-next={isNext || undefined}
disabled={!ready}
key={step}
onClick={() => {
if (ready) setMainTab(step);
if (ready) {
setMainTab(step);
return;
}
// Locked-step click handling: instead of a
// silent no-op (the old behavior with `disabled`),
// route the user to the missing prerequisite and
// surface a helpful inline notice. Falls back to
// a plain notice if no specific prereq is known.
const prereq = lockedPrereqStep[step];
const message = lockedPrereqMessage[step] || lockReason[step];
if (message) {
setWorkspaceNotice({ level: "warning", message });
}
if (prereq) {
setMainTab(prereq);
}
}}
role="tab"
title={tooltip}
Expand Down
8 changes: 7 additions & 1 deletion src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,13 @@ def get_daily_quota_for_plan(plan_tier: str):
normalized_plan = (plan_tier or AUTH_DEFAULT_PLAN_TIER).strip().lower()
if normalized_plan in {"admin", "internal"}:
return {"max_calls": None, "max_total_tokens": None, "plan_tier": normalized_plan}
if normalized_plan in {"paid", "pro", "plus"}:
# Paid tiers share the same daily cost-cap regardless of monthly
# feature limits — the monthly TIER_CAPS in backend/tiers.py is
# where tier differentiation actually lives. The daily cap here
# is a runaway-cost safety net; Pro and Business get the same
# generous daily budget so a Business user isn't silently throttled
# mid-workflow just because they fell through to the FREE bucket.
if normalized_plan in {"paid", "pro", "plus", "business"}:
return {
"max_calls": PAID_TIER_MAX_CALLS_PER_DAY,
"max_total_tokens": PAID_TIER_MAX_TOKENS_PER_DAY,
Expand Down
Loading
Loading