saitejatirunagari Claude Sonnet 4.6 commited on
Commit
a07a86c
Β·
1 Parent(s): 99c9c71

feat(ui): redesign to light-themed SaaS dashboard with guided setup flow

Browse files

Complete UI overhaul from dark developer-tool aesthetic to a premium light
SaaS dashboard. Key changes:

- Light color palette (#F7F9FC bg, white cards, blue/purple gradient accents)
- Inter font family for modern clean typography
- 7-step guided setup cards with numbered badges and helper text
- Two-column layout: config steps (left) + Run Readiness panel (right)
- Readiness scoring (0-100%) with levels: Getting Started β†’ Automation Pro
- Achievement badges: Resume Ready, Role Focused, Platform Explorer, etc.
- Live checklist with green checkmarks for completed setup items
- Friendly microcopy and estimated scan size/time
- All existing pipeline logic, results tabs, and functionality preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (3) hide show
  1. .claude/launch.json +11 -0
  2. HISTORY.md +33 -0
  3. ui.py +838 -275
.claude/launch.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "0.0.1",
3
+ "configurations": [
4
+ {
5
+ "name": "streamlit",
6
+ "runtimeExecutable": "streamlit",
7
+ "runtimeArgs": ["run", "ui.py", "--server.port", "8502", "--server.headless", "true"],
8
+ "port": 8502
9
+ }
10
+ ]
11
+ }
HISTORY.md CHANGED
@@ -4,6 +4,39 @@ A running log of everything built, fixed, and changed. Most recent first.
4
 
5
  ---
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  ## 2026-06-13 β€” Unified Platform Selector + ATS + HTML Rendering Fixes
8
 
9
  ### Changes
 
4
 
5
  ---
6
 
7
+ ## 2026-06-15 β€” UI Redesign v3: Light SaaS Dashboard
8
+
9
+ ### Visual Overhaul
10
+ - **Light theme**: Replaced dark (#0f1117) background with light (#F7F9FC) SaaS palette
11
+ - **Inter font**: Clean modern typography via Google Fonts import
12
+ - **Gradient accent**: Primary buttons and header use #2563EB β†’ #7C3AED gradient
13
+ - **White cards** with subtle borders (#E2E8F0) and soft shadows
14
+
15
+ ### Guided Setup Flow
16
+ - **7 step cards** replace the flat configuration layout β€” each has a number badge, title, helper text
17
+ - **Two-column layout**: Main config (left 75%) + Run Readiness sidebar (right 25%)
18
+ - **Hero card** at top: "Build your AI job search" with one-line description
19
+
20
+ ### Run Readiness Panel (right sidebar)
21
+ - **Readiness score**: 0–100% circular indicator based on 6 setup steps
22
+ - **Readiness levels**: Getting Started β†’ Balanced Setup β†’ Power Search Ready β†’ Automation Pro
23
+ - **Live checklist**: Green checkmarks for completed items, hollow circles for pending
24
+ - **Summary card**: Roles, locations, platforms, freshness, max jobs, AI match score
25
+ - **Achievement badges**: Resume Ready, Role Focused, Platform Explorer, Tracker Connected, Power Search
26
+ - **Start button**: Disabled until required fields (resume, roles, locations, platforms) are filled
27
+
28
+ ### UX Improvements
29
+ - **Microcopy**: Green success messages after each step ("🎯 Great focus β€” 3 target roles selected")
30
+ - **Estimated scan**: Shows ~N jobs and ~M minutes based on platform count Γ— max_jobs
31
+ - **Friendly labels**: "Job freshness" instead of "Days Posted", "AI match score" instead of "Min Score for LLM Resume"
32
+ - **Google Sheet card**: Soft amber warning instead of harsh error, with expandable "Advanced setup" instructions
33
+ - **New Search button**: Appears at top of results to return to config without reload
34
+
35
+ ### Modified Files
36
+ - `ui.py` β€” Complete rewrite: CSS, layout, step cards, readiness panel, gamification
37
+
38
+ ---
39
+
40
  ## 2026-06-13 β€” Unified Platform Selector + ATS + HTML Rendering Fixes
41
 
42
  ### Changes
ui.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Job Automation Agent β€” Streamlit UI v2
3
  Run: streamlit run ui.py
4
  """
5
  import streamlit as st
@@ -28,142 +28,449 @@ st.set_page_config(
28
  initial_sidebar_state="collapsed",
29
  )
30
 
31
- # ── CSS ───────────────────────────────────────────────────────────────────────
 
 
32
  st.markdown("""
33
  <style>
 
 
34
  /* ── Global ── */
35
- .stApp { background: #0f1117; color: #e0e0e0; }
 
 
 
 
36
  [data-testid="stSidebar"] { display: none !important; }
37
- .block-container { padding: 1.2rem 2rem !important; max-width: 1300px !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
- /* ── Top header ── */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  .jaa-header {
41
- background: linear-gradient(135deg, #1648BE 0%, #0a1f5e 100%);
42
- border-radius: 12px; padding: 16px 24px; margin-bottom: 18px;
 
 
43
  display: flex; align-items: center; justify-content: space-between;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
45
- .jaa-header-title { color: #fff; font-size: 1.6rem; font-weight: 800; margin: 0; }
46
- .jaa-header-sub { color: #a8c4ff; font-size: 0.85rem; margin: 2px 0 0; }
47
 
48
- /* ── History panel ── */
49
- .history-panel {
50
- background: #13162a; border: 1px solid #2a2f55; border-radius: 12px;
51
- padding: 18px 20px; margin-bottom: 18px;
 
 
 
 
 
52
  }
53
- .history-run {
54
- background: #1a1d27; border: 1px solid #2a2f45; border-radius: 10px;
55
- padding: 12px 16px; margin-bottom: 8px;
56
- display: flex; align-items: center; justify-content: space-between;
57
- gap: 12px;
58
  }
59
- .history-run-meta { flex: 1; }
60
- .history-run-date { font-size: 0.82rem; color: #6b7280; }
61
- .history-run-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
62
- .htag {
63
- padding: 2px 8px; border-radius: 12px; font-size: 0.78rem;
64
- background: #1a2540; color: #93c5fd;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  }
66
- .htag-red { background: #2b1a1a; color: #f87171; }
67
- .htag-green { background: #1a2b1a; color: #4ade80; }
68
 
69
- /* ── Config card ── */
70
- .config-section {
71
- background: #1a1d27; border: 1px solid #2a2f45; border-radius: 12px;
72
- padding: 20px 24px; margin-bottom: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  }
74
- .section-label {
75
- color: #93c5fd; font-size: 0.78rem; font-weight: 700;
76
- text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px;
 
 
 
 
 
 
 
 
 
77
  }
78
 
79
- /* ── Platform chips ── */
80
- .plat-row { display: flex; gap: 8px; flex-wrap: wrap; margin: 6px 0; }
81
- .plat-chip {
82
- border-radius: 20px; padding: 5px 14px; font-size: 0.82rem;
83
- font-weight: 600; border: 1.5px solid;
 
 
 
 
 
 
 
 
 
 
84
  }
85
- .plat-on { background: #1a2540; border-color: #1648BE; color: #93c5fd; }
86
- .plat-off { background: #1a1d27; border-color: #3a3f55; color: #6b7280; }
87
 
88
- /* ── Step pipeline ── */
89
  .steps-grid {
90
- display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
 
91
  gap: 8px; margin: 12px 0;
92
  }
93
  .step-card {
94
- background: #1a1d27; border: 1px solid #2a2f45; border-radius: 10px;
95
  padding: 10px 14px; display: flex; align-items: center; gap: 8px;
 
96
  }
97
- .step-card.active { border-color: #1648BE; background: #111e40; }
98
- .step-card.done { border-color: #2e7d32; background: #0d1f0d; }
99
- .step-card.error { border-color: #b71c1c; background: #1f0d0d; }
100
  .step-card.skip { opacity: 0.45; }
101
  .step-icon { font-size: 1.2rem; flex-shrink: 0; }
102
  .step-body { flex: 1; min-width: 0; }
103
- .step-title { font-size: 0.82rem; font-weight: 600; margin: 0;
104
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
105
- .step-detail{ font-size: 0.73rem; color: #9ca3af; margin: 1px 0 0;
106
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
107
- .step-time { font-size: 0.72rem; color: #4b5563; white-space: nowrap; }
 
 
 
 
 
108
 
109
  /* ── Log box ── */
110
  .log-box {
111
- background: #080a10; border: 1px solid #1e2235; border-radius: 8px;
112
- padding: 10px 14px; font-family: 'Courier New', monospace; font-size: 0.76rem;
113
- max-height: 160px; overflow-y: auto; color: #d1d5db;
114
  }
115
  .log-ok { color: #4ade80; } .log-warn { color: #facc15; }
116
  .log-err { color: #f87171; } .log-info { color: #93c5fd; }
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  /* ── Job card ── */
119
  .job-card {
120
- background: #1a1d27; border: 1px solid #2a2f45; border-radius: 12px;
121
- padding: 14px 18px; margin-bottom: 10px;
 
 
122
  }
123
- .job-card.jc-high { border-left: 4px solid #ef5350; }
124
- .job-card.jc-medium { border-left: 4px solid #ffc107; }
125
- .job-card.jc-low { border-left: 4px solid #4b5563; }
 
126
  .jc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
127
- .jc-title { font-size: 0.98rem; font-weight: 700; color: #e0e0e0; margin: 0; }
128
- .jc-company { font-size: 0.82rem; color: #9ca3af; margin: 2px 0; }
129
  .jc-badge {
130
- padding: 3px 10px; border-radius: 20px; font-size: 0.8rem; font-weight: 700;
131
  white-space: nowrap; flex-shrink: 0;
132
  }
133
- .badge-high { background: #2b1a1a; color: #ef5350; border: 1px solid #ef5350; }
134
- .badge-medium { background: #2b2200; color: #ffc107; border: 1px solid #ffc107; }
135
- .badge-low { background: #1a1d27; color: #9ca3af; border: 1px solid #4b5563; }
136
- .jc-meta { display: flex; gap: 12px; margin-top: 8px; align-items: center; flex-wrap: wrap; }
137
- .jc-ats-before { color: #6b7280; font-size: 0.8rem; }
138
- .jc-ats-after { color: #4ade80; font-size: 0.85rem; font-weight: 700; }
139
- .jc-ats-gain { color: #34d399; font-size: 0.8rem; }
140
- .jc-salary { color: #93c5fd; font-size: 0.8rem; }
141
- .jc-platform { color: #6b7280; font-size: 0.78rem; }
142
-
143
- /* ── Metric summary row ── */
144
- .metrics-row { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
145
- .mbox {
146
- background: #1a1d27; border: 1px solid #2a2f45; border-radius: 10px;
147
- padding: 12px 16px; text-align: center; flex: 1; min-width: 90px;
148
  }
149
- .mbox .mv { font-size: 1.7rem; font-weight: 800; }
150
- .mbox .ml { font-size: 0.72rem; color: #6b7280; margin-top: 1px; }
151
- .mbox.blue .mv { color: #64b5f6; }
152
- .mbox.red .mv { color: #ef5350; }
153
- .mbox.yellow .mv { color: #ffc107; }
154
- .mbox.green .mv { color: #4caf50; }
155
- .mbox.purple .mv { color: #c084fc; }
156
 
157
- /* ── Buttons ── */
158
- .stButton > button {
159
- background: linear-gradient(90deg, #1648BE, #0d2d6e) !important;
160
- color: white !important; border: none !important; border-radius: 8px !important;
161
- padding: 8px 20px !important; font-weight: 600 !important;
162
  }
163
- .stButton > button:disabled { opacity: 0.5 !important; }
164
- .stProgress > div > div { background: #1648BE !important; }
165
- div[data-testid="stFileUploaderDropzone"] {
166
- background: #1a1d27 !important; border-color: #2a2f45 !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  }
168
  </style>
169
  """, unsafe_allow_html=True)
@@ -191,13 +498,13 @@ for _k, _v in _DEFAULTS.items():
191
  if _k not in st.session_state:
192
  st.session_state[_k] = _v
193
 
194
- # ── Shared progress queue (survives Streamlit reruns via session_state) ───────
195
  if "progress_q" not in st.session_state:
196
  st.session_state["progress_q"] = queue.Queue()
197
  _progress_q: queue.Queue = st.session_state["progress_q"]
198
 
199
 
200
- # ── Pipeline steps definition ─────────────────────────────────────────────────
201
  PIPELINE_STEPS = [
202
  {"id": "resume", "icon": "πŸ“„", "title": "Parse Resume"},
203
  {"id": "profile", "icon": "🧠", "title": "Build Profile"},
@@ -215,7 +522,9 @@ PIPELINE_STEPS = [
215
  _STEP_TITLE_MAP = {s["id"]: s["title"] for s in PIPELINE_STEPS}
216
 
217
 
218
- # ── Helpers ───────────────────────────────────────────────────────────────────
 
 
219
  def _score_color_cls(score):
220
  if score >= 8: return "high"
221
  if score >= 6: return "medium"
@@ -247,7 +556,7 @@ def _render_steps(steps_state: dict) -> str:
247
  <div class="step-card {css}">
248
  <span class="step-icon">{icon}</span>
249
  <div class="step-body">
250
- <p class="step-title">{s['icon']} {s['title']}</p>
251
  <p class="step-detail">{safe or ('Waiting…' if status=='pending' else '')}</p>
252
  </div>
253
  <span class="step-time">{elapsed}</span>
@@ -285,13 +594,13 @@ def _job_card_html(job: dict, rank: int) -> str:
285
  if ats_b is not None and ats_a is not None:
286
  ats_html = (
287
  f'<span class="jc-ats-before">ATS {ats_b}%</span>'
288
- f'<span style="color:#4b5563">β†’</span>'
289
  f'<span class="jc-ats-after">{ats_a}%</span>'
290
  f'<span class="jc-ats-gain">(+{imp}pp)</span>'
291
  )
292
 
293
  sal_html = f'<span class="jc-salary">πŸ’° {salary}</span>' if salary and salary != "Not specified" else ""
294
- link_html = f'<a href="{url}" target="_blank" style="color:#64b5f6;font-size:0.8rem;text-decoration:none">Apply β†’</a>' if url else ""
295
 
296
  t = title.replace("<","&lt;").replace(">","&gt;")
297
  co = company.replace("<","&lt;").replace(">","&gt;")
@@ -334,29 +643,53 @@ def _metrics_html(results: list) -> str:
334
  </div>"""
335
 
336
 
337
- # ── Google Sheet status ───────────────────────────────────────────────────────
338
  def _sheets_configured() -> tuple[bool, str]:
339
  if os.path.exists("google_credentials.json"):
340
- return True, "Service account βœ“"
341
  if os.path.exists("google_token.json"):
342
- return True, "OAuth token βœ“"
343
  if os.path.exists("google_oauth_client.json"):
344
- return False, "Run setup_google.py to complete OAuth"
345
- return False, "No credentials β€” run setup_google.py"
 
 
 
 
 
 
 
 
 
 
 
 
346
 
 
 
 
 
 
347
 
348
- # ════════════════════════════════════════════════════════════════════════════════
 
349
  # HEADER
350
- # ════════════════════════════════════════════════════════════════════════════════
 
 
 
351
  hdr_l, hdr_r = st.columns([5, 1])
352
  with hdr_l:
353
- st.markdown("""
 
 
 
354
  <div class="jaa-header">
355
  <div class="jaa-header-left">
356
  <p class="jaa-header-title">πŸ€– Job Automation Agent</p>
357
- <p class="jaa-header-sub">AI-powered job search Β· Multi-model assessment Β· ATS resume generation</p>
358
  </div>
359
- </div>""", unsafe_allow_html=True)
 
360
 
361
  with hdr_r:
362
  st.markdown("<br>", unsafe_allow_html=True)
@@ -366,14 +699,14 @@ with hdr_r:
366
  st.rerun()
367
 
368
 
369
- # ════════════════════════════════════════════════════════════════════════════════
370
  # HISTORY PANEL
371
- # ════════════════════════════════════════════════════════════════════════════════
372
  if st.session_state.show_history:
373
  from src.run_history import list_runs, load_run
374
  past_runs = list_runs()
375
 
376
- st.markdown('<div class="history-panel">', unsafe_allow_html=True)
377
  st.markdown("### πŸ“œ Run History")
378
 
379
  if not past_runs:
@@ -416,7 +749,7 @@ if st.session_state.show_history:
416
  st.session_state.show_history = False
417
  st.rerun()
418
 
419
- st.markdown('</div>', unsafe_allow_html=True)
420
 
421
  if st.session_state.loaded_run:
422
  st.info(f"πŸ“‚ Showing results from run: **{st.session_state.loaded_run}**")
@@ -424,33 +757,73 @@ if st.session_state.show_history:
424
  st.divider()
425
 
426
 
427
- # ════════════════════════════════════════════════════════════════════════════════
428
- # CONFIGURATION
429
- # ════════════════════════════════════════════════════════════════════════════════
430
- with st.expander("βš™οΈ Configure Run", expanded=not (st.session_state.running or st.session_state.results)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
 
432
- # ── Row 1: Resume + Roles ──
433
- col_res, col_roles = st.columns([1, 2])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
 
435
- with col_res:
436
- st.markdown('<p class="section-label">πŸ“„ Resume</p>', unsafe_allow_html=True)
437
- resume_file = st.file_uploader("Upload PDF", type=["pdf"], key="resume_upload",
438
- label_visibility="collapsed")
 
 
439
  if resume_file:
440
  os.makedirs("data/resume", exist_ok=True)
441
  with open("data/resume/resume.pdf", "wb") as f:
442
  f.write(resume_file.read())
443
- st.success("βœ“ Saved")
444
- if os.path.exists("data/resume/resume.pdf"):
 
 
445
  fsize = os.path.getsize("data/resume/resume.pdf") // 1024
446
- st.caption(f"πŸ“„ resume.pdf ({fsize} KB)")
 
 
 
447
  else:
448
- st.warning("No resume uploaded yet")
449
 
450
- with col_roles:
451
- st.markdown('<p class="section-label">🎯 Target Roles</p>', unsafe_allow_html=True)
 
452
  roles = st.multiselect(
453
- "Roles",
454
  options=[
455
  "Product Manager", "Senior Product Manager", "AI Product Manager",
456
  "Technical Product Manager", "Data Product Manager", "Platform Product Manager",
@@ -458,146 +831,351 @@ with st.expander("βš™οΈ Configure Run", expanded=not (st.session_state.running
458
  "Associate Product Manager", "Head of Product",
459
  ],
460
  default=["Product Manager", "Senior Product Manager", "AI Product Manager"],
461
- label_visibility="collapsed",
462
  )
 
 
463
 
464
- st.divider()
465
-
466
- # ── Row 2: Locations + Params ──
467
- col_loc, col_days, col_max = st.columns([3, 1, 1])
468
-
469
- with col_loc:
470
- st.markdown('<p class="section-label">πŸ“ Locations</p>', unsafe_allow_html=True)
471
  locations = st.multiselect(
472
- "Locations",
473
  options=["India", "Bangalore", "Hyderabad", "Mumbai", "Delhi NCR",
474
  "Pune", "Chennai", "Noida", "Remote", "Worldwide"],
475
  default=["India", "Bangalore"],
476
- label_visibility="collapsed",
477
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
 
479
- with col_days:
480
- st.markdown('<p class="section-label">πŸ“… Days Posted</p>', unsafe_allow_html=True)
481
- days_posted = st.selectbox("Days", [3, 7, 14, 30], index=1,
482
- label_visibility="collapsed")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
 
484
- with col_max:
485
- st.markdown('<p class="section-label">πŸ”’ Max / Platform</p>', unsafe_allow_html=True)
486
- max_jobs = st.number_input("Max", min_value=5, max_value=100, value=25, step=5,
487
- label_visibility="collapsed",
488
- help="Max total jobs to fetch from each platform (not per query)")
 
 
 
 
 
 
 
 
 
 
 
 
489
 
490
- st.divider()
 
 
 
 
 
 
 
 
 
 
 
491
 
492
- # ── Row 3: Unified platform selector ──
493
- st.markdown('<p class="section-label">🌐 Job Platforms</p>', unsafe_allow_html=True)
494
- from src.ever_jobs_bridge.platforms import PLATFORM_GROUPS, INDIA_DEFAULT_PLATFORMS, EVER_JOBS_PLATFORMS
495
-
496
- ej_col1, ej_col2, ej_col3 = st.columns(3)
497
-
498
- with ej_col1:
499
- with st.expander(f"πŸ” Search Boards ({len(PLATFORM_GROUPS['Search Boards'])})"):
500
- sb_options = PLATFORM_GROUPS["Search Boards"]
501
- sb_defaults = [p for p in INDIA_DEFAULT_PLATFORMS if p in sb_options]
502
- selected_search_boards = st.multiselect(
503
- "Search Boards",
504
- options=sb_options,
505
- default=sb_defaults,
506
- format_func=lambda k: EVER_JOBS_PLATFORMS.get(k, {}).get("display", k),
507
- label_visibility="collapsed",
508
- key="ej_search_boards",
509
- )
510
 
511
- with ej_col2:
512
- with st.expander(f"🏒 ATS Platforms ({len(PLATFORM_GROUPS['ATS Platforms'])})"):
513
- ats_options = PLATFORM_GROUPS["ATS Platforms"]
514
- selected_ats = st.multiselect(
515
- "ATS Platforms",
516
- options=ats_options,
517
- default=[],
518
- format_func=lambda k: EVER_JOBS_PLATFORMS.get(k, {}).get("display", k),
519
- label_visibility="collapsed",
520
- key="ej_ats_platforms",
521
- help="Applicant Tracking System platforms (Greenhouse, Lever, Workday, etc.). These are job POSTING platforms, not resume scorers.",
522
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
 
524
- with ej_col3:
525
- with st.expander(f"🏭 Company Pages ({len(PLATFORM_GROUPS['Company Pages'])})"):
526
- cp_options = PLATFORM_GROUPS["Company Pages"]
527
- selected_company = st.multiselect(
528
- "Company Pages",
529
- options=cp_options,
530
- default=[],
531
- format_func=lambda k: EVER_JOBS_PLATFORMS.get(k, {}).get("display", k),
532
- label_visibility="collapsed",
533
- key="ej_company_pages",
534
- help="Direct company career page scrapers. Many are US-centric and slower than search boards.",
535
  )
 
 
 
 
 
 
 
 
 
 
 
 
536
 
537
- ever_jobs_platforms = selected_search_boards + selected_ats + selected_company
538
- use_ever_jobs = len(ever_jobs_platforms) > 0
 
 
 
 
 
 
 
 
 
 
 
539
 
540
- if len(ever_jobs_platforms) > 30:
541
- st.warning(
542
- f"⚠ **{len(ever_jobs_platforms)} platforms selected.** "
543
- "Runs with >30 platforms may take 5–10 minutes. "
544
- "Consider reducing to your top platforms for faster results.",
545
- icon="⚠️",
546
- )
547
- elif use_ever_jobs:
548
- st.caption(f"βœ“ {len(ever_jobs_platforms)} platform(s) selected")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
 
550
- st.divider()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
 
552
- # ── Row 4: AI + Output settings ──
553
- col_score, col_gs = st.columns([2, 3])
 
 
 
 
 
 
 
 
 
 
 
 
554
 
555
- with col_score:
556
- st.markdown('<p class="section-label">πŸ€– Min Score for LLM Resume</p>', unsafe_allow_html=True)
557
- min_score = st.slider("Min score", 1, 10, 1, label_visibility="collapsed",
558
- help="Jobs scoring β‰₯ this get a fully AI-tailored resume. Set to 1 to generate LLM resumes for ALL jobs.")
 
 
 
 
 
 
 
 
559
 
560
- with col_gs:
561
- st.markdown('<p class="section-label">πŸ“Š Google Sheet</p>', unsafe_allow_html=True)
562
- sheets_ok, sheets_status = _sheets_configured()
563
- if sheets_ok:
564
- st.success(f"βœ“ {sheets_status}", icon="βœ…")
565
- else:
566
- st.warning(f"⚠ {sheets_status}", icon="⚠️")
567
- from config import GOOGLE as _G
568
- st.caption(f"Sheet ID: `{_G['sheet_id'][:20]}…` Tab: `{_G['sheet_tab']}`")
569
-
570
-
571
- # ════════════════════════════════════════════════════════════════════════════════
572
- # START BUTTON
573
- # ════════════════════════════════════════════════════════════════════════════════
574
- btn_col = st.columns([1, 2, 1])[1]
575
- with btn_col:
576
- start = st.button(
577
- "β–Ά Start Search" if not st.session_state.running else "⏳ Running…",
578
- disabled=st.session_state.running,
579
- use_container_width=True,
580
- type="primary",
581
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
 
583
- # Progress placeholders (always declared β€” updated when running)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  progress_placeholder = st.empty()
585
  steps_placeholder = st.empty()
586
  log_placeholder = st.empty()
587
  done_placeholder = st.empty()
588
 
589
 
590
- # ════════════════════════════════════════════════════════════════════════════════
591
  # LAUNCH PIPELINE
592
- # ════════════════════════════════════════════════════════════════════════════════
593
- if start and not st.session_state.running:
594
  if not os.path.exists("data/resume/resume.pdf"):
595
- st.error("Please upload your resume above first.")
596
  elif not roles:
597
- st.error("Please select at least one role.")
598
  elif not locations:
599
  st.error("Please select at least one location.")
 
 
600
  else:
 
 
 
 
 
 
 
 
601
  st.session_state.running = True
602
  st.session_state.results = None
603
  st.session_state.loaded_run = ""
@@ -624,7 +1202,6 @@ if start and not st.session_state.running:
624
  _platforms=platforms_cfg, _jscfg=job_search_cfg, _ocfg=output_cfg,
625
  _q=_progress_q):
626
  import time as _t, traceback as _tb
627
- # Bind THIS session's queue (shadows module-level helpers)
628
  _progress_q = _q
629
  def _q_log(msg): _q.put(("log", msg))
630
  def _q_progress(pct, lbl=""): _q.put(("progress", pct, lbl))
@@ -763,7 +1340,6 @@ if start and not st.session_state.running:
763
  else:
764
  _step_skip("naukri")
765
 
766
- # All non-legacy platforms (150+) β†’ EverJobsScraper
767
  _ej_platforms = [p for p in _all_plats if p not in _legacy_keys]
768
  if _ej_platforms:
769
  from src.scrapers.ever_jobs import EverJobsScraper
@@ -787,7 +1363,6 @@ if start and not st.session_state.running:
787
  if outer_done:
788
  break
789
  for loc in _jscfg["locations"]:
790
- # ── Platform-level total cap (not per-query) ──
791
  if len(platform_jobs) >= MAX_PER_PLAT:
792
  outer_done = True
793
  break
@@ -821,7 +1396,6 @@ if start and not st.session_state.running:
821
  _q.put(("step", plat_id, "active",
822
  f"Role {ri+1}/{n_roles} β€” {len(platform_jobs)} jobs so far", ""))
823
 
824
- # Bulk-fetch descriptions
825
  needs_desc = [j for j in platform_jobs if not j.description]
826
  if needs_desc and hasattr(scraper, "get_details_bulk"):
827
  _q.put(("step", plat_id, "active",
@@ -864,7 +1438,7 @@ if start and not st.session_state.running:
864
  _q_progress(78, "Assessment complete!")
865
  _q_log(f"βœ… Assessment done β€” High: {high}, Good: {med}, Low: {low}")
866
 
867
- # ── Generate resumes (ALL jobs, no cap) ──
868
  t0 = _t.time()
869
  llm_elig = sum(1 for j in assessed_jobs if j.get("relevance_score",0) >= _min_score)
870
  phase2_cfgs = [m for m in ASSESSMENT_MODELS if m.get("phase2") and m.get("api_key")]
@@ -883,7 +1457,7 @@ if start and not st.session_state.running:
883
  assessed_jobs = customizer.customize_for_jobs(
884
  assessed_jobs,
885
  min_score_for_llm=_min_score,
886
- max_llm_resumes=len(assessed_jobs), # NO CAP β€” generate for all
887
  generate_all=True,
888
  model_cfgs=phase2_cfgs,
889
  progress_cb=_resume_cb,
@@ -932,7 +1506,6 @@ if start and not st.session_state.running:
932
  _q_log(f"βœ… Excel: {excel_path}")
933
  _q_log(f"βœ… Resumes folder: {_ocfg['resumes_dir']}")
934
 
935
- # ── Save to run history ──
936
  try:
937
  from src.run_history import save_run
938
  save_run(assessed_jobs, {
@@ -960,7 +1533,7 @@ if start and not st.session_state.running:
960
  st.rerun()
961
 
962
 
963
- # ── Drain progress queue into session state ───────────────────────────────────
964
  if st.session_state.running:
965
  while True:
966
  try:
@@ -991,7 +1564,7 @@ if st.session_state.running:
991
  break
992
 
993
 
994
- # ── Render progress ───────────────────────────────────────────────────────────
995
  if st.session_state.running or (st.session_state.progress_pct and st.session_state.results is None):
996
  pct = st.session_state.progress_pct
997
  lbl = st.session_state.progress_label
@@ -1003,30 +1576,28 @@ if st.session_state.running or (st.session_state.progress_pct and st.session_sta
1003
 
1004
  if st.session_state.log_msgs:
1005
  log_placeholder.html(
1006
- '<p style="font-weight:700;color:#e0e0e0;margin:0 0 4px 0">Live Log</p>' +
1007
  _render_log(st.session_state.log_msgs)
1008
  )
1009
 
1010
- # ── Done banner ───────────────────────────────────────────────────────────────
1011
  if not st.session_state.running and st.session_state.results:
1012
  n = len(st.session_state.results)
1013
  high = sum(1 for j in st.session_state.results if j.get("relevance_score", 0) >= 8)
1014
  pdfs = sum(1 for j in st.session_state.results if j.get("resume_pdf_path"))
1015
  hist_note = f" Β· Saved to history" if st.session_state.loaded_run else ""
1016
  done_placeholder.success(
1017
- f"βœ… **Done!** Found **{n} jobs** Β· **{high} high priority** Β· **{pdfs} PDFs ready**{hist_note} β€” "
1018
- "see the tabs below."
1019
  )
1020
 
1021
- # Poll while running
1022
  if st.session_state.running:
1023
  time.sleep(0.8)
1024
  st.rerun()
1025
 
1026
 
1027
- # ═══════════════════════���════════════════════════════════════════════════════════
1028
- # RESULTS TABS (shown once results are available)
1029
- # ════════════════════════════════════════════════════════════════════════════════
1030
  results = st.session_state.results
1031
 
1032
  if results is not None:
@@ -1039,10 +1610,8 @@ if results is not None:
1039
  # TAB 1 β€” RESULTS
1040
  # ══════════════════════════════════════════════════════════════════════════
1041
  with tab_results:
1042
- # Metrics summary
1043
  st.html(_metrics_html(results))
1044
 
1045
- # Download buttons
1046
  dl1, dl2, dl3 = st.columns(3)
1047
  with dl1:
1048
  xp = st.session_state.excel_path
@@ -1053,14 +1622,11 @@ if results is not None:
1053
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1054
  use_container_width=True)
1055
  with dl2:
1056
- # Current run's resume folder only (most recent date subfolder)
1057
  resume_base = Path("data/output/resumes")
1058
  if st.session_state.loaded_run:
1059
- # For a loaded historical run, find the date from the first job
1060
  first_res = next((j.get("resume_path","") for j in results if j.get("resume_path")), "")
1061
  run_folder = Path(first_res).parent if first_res else None
1062
  else:
1063
- # Current run: most recently modified date subfolder
1064
  date_dirs = sorted(
1065
  [d for d in resume_base.iterdir() if d.is_dir()],
1066
  key=lambda d: d.stat().st_mtime, reverse=True,
@@ -1081,11 +1647,11 @@ if results is not None:
1081
  n_pdf = len(pdf_f)
1082
  n_docx = len(docx_f)
1083
  st.download_button(
1084
- f"⬇ Download Resumes ({n_docx} DOCX + {n_pdf} PDF = {len(all_rf)} files)",
1085
  zbuf.read(), "tailored_resumes.zip", "application/zip",
1086
  use_container_width=True,
1087
  )
1088
- st.caption(f"πŸ“ Folder: `{run_folder}`")
1089
  with dl3:
1090
  if sheets_ok:
1091
  from config import GOOGLE as _GC
@@ -1094,7 +1660,6 @@ if results is not None:
1094
 
1095
  st.divider()
1096
 
1097
- # Filters
1098
  col_f1, col_f2, col_f3 = st.columns(3)
1099
  with col_f1:
1100
  f_score = st.slider("Min score", 1, 10, 1, key="f_score")
@@ -1118,13 +1683,11 @@ if results is not None:
1118
  st.caption(f"Showing **{len(filtered)}** of **{n_res}** jobs")
1119
 
1120
  if view_mode.startswith("πŸ“‹"):
1121
- # Card view β€” top 10
1122
  for rank, job in enumerate(filtered[:10], 1):
1123
  st.html(_job_card_html(job, rank))
1124
  if len(filtered) > 10:
1125
  st.caption(f"… and {len(filtered)-10} more. Switch to Table view to see all.")
1126
  else:
1127
- # Full table
1128
  if not filtered:
1129
  st.info("No jobs match the current filters.")
1130
  else:
@@ -1453,8 +2016,8 @@ if results is not None:
1453
  colored += f'<span style="color:#d1d5db">{safe}</span>'
1454
 
1455
  st.markdown(
1456
- f'<div style="background:#080a10;border:1px solid #1e2235;border-radius:8px;'
1457
- f'padding:14px;font-family:Courier New,monospace;font-size:0.78rem;'
1458
  f'max-height:500px;overflow-y:auto;white-space:pre-wrap">'
1459
  f'{colored}</div>',
1460
  unsafe_allow_html=True,
@@ -1477,15 +2040,15 @@ if results is not None:
1477
  st.rerun()
1478
 
1479
  else:
1480
- # No results yet β€” show a welcome state
1481
- st.markdown("""
1482
- <div style="text-align:center;padding:48px 24px;color:#6b7280;">
1483
- <div style="font-size:3rem">πŸ€–</div>
1484
- <h3 style="color:#9ca3af;margin:12px 0">Ready to find your next PM role</h3>
1485
- <p style="max-width:420px;margin:0 auto;font-size:0.9rem">
1486
- Configure your search above, then click <strong style="color:#64b5f6">β–Ά Start Search</strong>.<br>
1487
- The agent will scrape multiple platforms, assess every job with 6 AI models,
 
1488
  and generate ATS-optimized resumes for all matches.
1489
  </p>
1490
- </div>
1491
- """, unsafe_allow_html=True)
 
1
  """
2
+ Job Automation Agent β€” Streamlit UI v3 (Light SaaS Dashboard)
3
  Run: streamlit run ui.py
4
  """
5
  import streamlit as st
 
28
  initial_sidebar_state="collapsed",
29
  )
30
 
31
+ # ══════════════════════════════════════════════════════════════════════════════
32
+ # CSS β€” Light SaaS Dashboard Theme
33
+ # ══════════════════════════════════════════════════════════════════════════════
34
  st.markdown("""
35
  <style>
36
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
37
+
38
  /* ── Global ── */
39
+ .stApp {
40
+ background: #F7F9FC !important;
41
+ color: #0F172A;
42
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
43
+ }
44
  [data-testid="stSidebar"] { display: none !important; }
45
+ .block-container {
46
+ padding: 1rem 2rem !important;
47
+ max-width: 1400px !important;
48
+ }
49
+ [data-testid="stHeader"] { background: transparent !important; }
50
+
51
+ /* ── Streamlit overrides ── */
52
+ h1, h2, h3, h4, h5, h6 {
53
+ font-family: 'Inter', -apple-system, sans-serif !important;
54
+ color: #0F172A !important;
55
+ }
56
+ p, span, label, .stMarkdown { color: #0F172A; }
57
+ .stSelectbox label, .stMultiSelect label, .stSlider label,
58
+ .stNumberInput label, .stFileUploader label {
59
+ color: #334155 !important; font-weight: 500 !important;
60
+ }
61
+
62
+ /* ── Buttons ── */
63
+ .stButton > button[kind="primary"], .stButton > button {
64
+ background: linear-gradient(135deg, #2563EB 0%, #7C3AED 100%) !important;
65
+ color: white !important; border: none !important; border-radius: 10px !important;
66
+ padding: 10px 24px !important; font-weight: 600 !important;
67
+ font-family: 'Inter', sans-serif !important;
68
+ transition: all 0.2s ease !important;
69
+ box-shadow: 0 2px 8px rgba(37,99,235,0.25) !important;
70
+ }
71
+ .stButton > button:hover {
72
+ transform: translateY(-1px) !important;
73
+ box-shadow: 0 4px 16px rgba(37,99,235,0.35) !important;
74
+ }
75
+ .stButton > button:disabled {
76
+ opacity: 0.5 !important;
77
+ transform: none !important;
78
+ box-shadow: none !important;
79
+ }
80
+
81
+ /* Secondary / ghost buttons */
82
+ .secondary-btn .stButton > button {
83
+ background: white !important;
84
+ color: #2563EB !important;
85
+ border: 1.5px solid #E2E8F0 !important;
86
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04) !important;
87
+ }
88
+ .secondary-btn .stButton > button:hover {
89
+ border-color: #2563EB !important;
90
+ background: #F0F4FF !important;
91
+ }
92
+
93
+ /* ── Progress bar ── */
94
+ .stProgress > div > div { background: linear-gradient(90deg, #2563EB, #7C3AED) !important; }
95
+
96
+ /* ── File uploader ── */
97
+ div[data-testid="stFileUploaderDropzone"] {
98
+ background: #FFFFFF !important;
99
+ border: 2px dashed #CBD5E1 !important;
100
+ border-radius: 12px !important;
101
+ transition: all 0.2s ease;
102
+ }
103
+ div[data-testid="stFileUploaderDropzone"]:hover {
104
+ border-color: #2563EB !important;
105
+ background: #F0F4FF !important;
106
+ }
107
 
108
+ /* ── Inputs ── */
109
+ .stTextInput > div > div > input,
110
+ .stTextArea > div > div > textarea,
111
+ .stSelectbox > div > div,
112
+ .stMultiSelect > div {
113
+ background: #FFFFFF !important;
114
+ border-color: #E2E8F0 !important;
115
+ border-radius: 8px !important;
116
+ color: #0F172A !important;
117
+ }
118
+
119
+ /* ── Expander ── */
120
+ .streamlit-expanderHeader {
121
+ background: #FFFFFF !important;
122
+ border: 1px solid #E2E8F0 !important;
123
+ border-radius: 10px !important;
124
+ color: #334155 !important;
125
+ font-weight: 600 !important;
126
+ }
127
+ details {
128
+ border: 1px solid #E2E8F0 !important;
129
+ border-radius: 10px !important;
130
+ background: #FFFFFF !important;
131
+ }
132
+
133
+ /* ── Tabs ── */
134
+ .stTabs [data-baseweb="tab-list"] {
135
+ gap: 4px;
136
+ background: #F1F5F9;
137
+ border-radius: 12px;
138
+ padding: 4px;
139
+ }
140
+ .stTabs [data-baseweb="tab"] {
141
+ border-radius: 8px !important;
142
+ color: #64748B !important;
143
+ font-weight: 500 !important;
144
+ padding: 8px 16px !important;
145
+ }
146
+ .stTabs [aria-selected="true"] {
147
+ background: white !important;
148
+ color: #2563EB !important;
149
+ font-weight: 600 !important;
150
+ box-shadow: 0 1px 3px rgba(0,0,0,0.08) !important;
151
+ }
152
+
153
+ /* ── Divider ── */
154
+ hr { border-color: #E2E8F0 !important; opacity: 0.5 !important; }
155
+
156
+ /* ══════════════════════════════════════════���═══════════════════════════
157
+ CUSTOM COMPONENT CLASSES
158
+ ══════════════════════════════════════════════════════════════════════ */
159
+
160
+ /* ── Header ── */
161
  .jaa-header {
162
+ background: linear-gradient(135deg, #2563EB 0%, #7C3AED 100%);
163
+ border-radius: 16px;
164
+ padding: 20px 28px;
165
+ margin-bottom: 20px;
166
  display: flex; align-items: center; justify-content: space-between;
167
+ box-shadow: 0 4px 20px rgba(37,99,235,0.2);
168
+ }
169
+ .jaa-header-left { flex: 1; }
170
+ .jaa-header-title {
171
+ color: #fff; font-size: 1.5rem; font-weight: 800;
172
+ margin: 0; letter-spacing: -0.3px;
173
+ font-family: 'Inter', sans-serif;
174
+ }
175
+ .jaa-header-sub {
176
+ color: rgba(255,255,255,0.8); font-size: 0.85rem;
177
+ margin: 4px 0 0; font-weight: 400;
178
+ }
179
+ .jaa-header-badge {
180
+ background: rgba(255,255,255,0.15);
181
+ backdrop-filter: blur(10px);
182
+ border: 1px solid rgba(255,255,255,0.2);
183
+ border-radius: 20px;
184
+ padding: 6px 14px;
185
+ color: white; font-size: 0.8rem; font-weight: 600;
186
+ white-space: nowrap;
187
  }
 
 
188
 
189
+ /* ── Step Card ── */
190
+ .step-card-container {
191
+ background: #FFFFFF;
192
+ border: 1px solid #E2E8F0;
193
+ border-radius: 14px;
194
+ padding: 20px 24px;
195
+ margin-bottom: 16px;
196
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
197
+ transition: all 0.2s ease;
198
  }
199
+ .step-card-container:hover {
200
+ box-shadow: 0 4px 12px rgba(0,0,0,0.06);
 
 
 
201
  }
202
+ .step-card-container.completed {
203
+ border-left: 3px solid #16A34A;
204
+ }
205
+ .step-card-header {
206
+ display: flex; align-items: center; gap: 12px;
207
+ margin-bottom: 8px;
208
+ }
209
+ .step-number {
210
+ width: 28px; height: 28px;
211
+ background: linear-gradient(135deg, #2563EB, #7C3AED);
212
+ border-radius: 8px;
213
+ display: flex; align-items: center; justify-content: center;
214
+ color: white; font-weight: 700; font-size: 0.85rem;
215
+ flex-shrink: 0;
216
+ }
217
+ .step-number.done {
218
+ background: #16A34A;
219
+ }
220
+ .step-title-text {
221
+ font-size: 1rem; font-weight: 700;
222
+ color: #0F172A; margin: 0;
223
+ }
224
+ .step-helper {
225
+ font-size: 0.82rem; color: #64748B;
226
+ margin: 0 0 12px 40px; line-height: 1.4;
227
  }
 
 
228
 
229
+ /* ── Readiness Panel ── */
230
+ .readiness-panel {
231
+ background: #FFFFFF;
232
+ border: 1px solid #E2E8F0;
233
+ border-radius: 14px;
234
+ padding: 24px;
235
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
236
+ }
237
+ .readiness-title {
238
+ font-size: 1.1rem; font-weight: 700; color: #0F172A;
239
+ margin: 0 0 16px 0;
240
+ }
241
+ .readiness-progress-ring {
242
+ width: 100px; height: 100px; margin: 0 auto 16px;
243
+ position: relative;
244
+ }
245
+ .readiness-score {
246
+ text-align: center; font-size: 2rem; font-weight: 800;
247
+ background: linear-gradient(135deg, #2563EB, #7C3AED);
248
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
249
+ margin: 0 0 4px;
250
+ }
251
+ .readiness-label {
252
+ text-align: center; font-size: 0.82rem; color: #64748B;
253
+ margin: 0 0 20px;
254
+ }
255
+ .readiness-badge {
256
+ display: inline-block;
257
+ background: linear-gradient(135deg, #EFF6FF, #F0ECFF);
258
+ border: 1px solid #BFDBFE;
259
+ border-radius: 20px;
260
+ padding: 4px 12px;
261
+ font-size: 0.78rem; font-weight: 600; color: #2563EB;
262
+ text-align: center;
263
+ width: 100%;
264
+ box-sizing: border-box;
265
+ margin-bottom: 16px;
266
+ }
267
+ .readiness-badge.gold {
268
+ background: linear-gradient(135deg, #FFFBEB, #FEF3C7);
269
+ border-color: #FCD34D; color: #92400E;
270
+ }
271
+ .checklist-item {
272
+ display: flex; align-items: center; gap: 10px;
273
+ padding: 8px 0;
274
+ border-bottom: 1px solid #F1F5F9;
275
+ font-size: 0.85rem;
276
+ }
277
+ .checklist-item:last-child { border-bottom: none; }
278
+ .check-done {
279
+ width: 20px; height: 20px;
280
+ background: #16A34A;
281
+ border-radius: 50%;
282
+ display: flex; align-items: center; justify-content: center;
283
+ color: white; font-size: 0.7rem; flex-shrink: 0;
284
+ }
285
+ .check-pending {
286
+ width: 20px; height: 20px;
287
+ border: 2px solid #CBD5E1;
288
+ border-radius: 50%;
289
+ flex-shrink: 0;
290
+ }
291
+ .check-label { color: #334155; font-weight: 500; }
292
+ .check-label.done { color: #16A34A; }
293
+ .check-label.pending { color: #94A3B8; }
294
+
295
+ .summary-row {
296
+ display: flex; justify-content: space-between;
297
+ padding: 6px 0;
298
+ font-size: 0.82rem;
299
+ border-bottom: 1px solid #F8FAFC;
300
+ }
301
+ .summary-key { color: #64748B; }
302
+ .summary-val { color: #0F172A; font-weight: 600; }
303
+
304
+ /* ── Achievement Badges ── */
305
+ .badge-row {
306
+ display: flex; flex-wrap: wrap; gap: 6px;
307
+ margin: 12px 0;
308
+ }
309
+ .achievement-badge {
310
+ padding: 4px 10px;
311
+ border-radius: 16px;
312
+ font-size: 0.72rem; font-weight: 600;
313
+ display: inline-flex; align-items: center; gap: 4px;
314
+ }
315
+ .badge-earned {
316
+ background: #F0FDF4; border: 1px solid #BBF7D0; color: #16A34A;
317
  }
318
+ .badge-locked {
319
+ background: #F8FAFC; border: 1px solid #E2E8F0; color: #CBD5E1;
320
+ }
321
+
322
+ /* ── Microcopy ── */
323
+ .micro-success {
324
+ background: #F0FDF4;
325
+ border: 1px solid #BBF7D0;
326
+ border-radius: 8px;
327
+ padding: 8px 14px;
328
+ font-size: 0.82rem; color: #16A34A; font-weight: 500;
329
+ margin: 8px 0;
330
  }
331
 
332
+ /* ── Platform cards ── */
333
+ .platform-summary {
334
+ background: #F8FAFC;
335
+ border: 1px solid #E2E8F0;
336
+ border-radius: 10px;
337
+ padding: 10px 14px;
338
+ display: flex; align-items: center; justify-content: space-between;
339
+ margin-bottom: 8px;
340
+ }
341
+ .platform-count {
342
+ background: #EFF6FF;
343
+ color: #2563EB;
344
+ border-radius: 16px;
345
+ padding: 2px 10px;
346
+ font-size: 0.78rem; font-weight: 700;
347
  }
 
 
348
 
349
+ /* ── Step pipeline (running) ── */
350
  .steps-grid {
351
+ display: grid;
352
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
353
  gap: 8px; margin: 12px 0;
354
  }
355
  .step-card {
356
+ background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 10px;
357
  padding: 10px 14px; display: flex; align-items: center; gap: 8px;
358
+ transition: all 0.2s ease;
359
  }
360
+ .step-card.active { border-color: #2563EB; background: #EFF6FF; }
361
+ .step-card.done { border-color: #16A34A; background: #F0FDF4; }
362
+ .step-card.error { border-color: #EF4444; background: #FEF2F2; }
363
  .step-card.skip { opacity: 0.45; }
364
  .step-icon { font-size: 1.2rem; flex-shrink: 0; }
365
  .step-body { flex: 1; min-width: 0; }
366
+ .step-title-run {
367
+ font-size: 0.82rem; font-weight: 600; margin: 0;
368
+ color: #0F172A;
369
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
370
+ }
371
+ .step-detail {
372
+ font-size: 0.73rem; color: #64748B; margin: 1px 0 0;
373
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
374
+ }
375
+ .step-time { font-size: 0.72rem; color: #94A3B8; white-space: nowrap; }
376
 
377
  /* ── Log box ── */
378
  .log-box {
379
+ background: #1E293B; border: 1px solid #334155; border-radius: 10px;
380
+ padding: 12px 16px; font-family: 'JetBrains Mono', 'Courier New', monospace;
381
+ font-size: 0.76rem; max-height: 160px; overflow-y: auto; color: #E2E8F0;
382
  }
383
  .log-ok { color: #4ade80; } .log-warn { color: #facc15; }
384
  .log-err { color: #f87171; } .log-info { color: #93c5fd; }
385
 
386
+ /* ── Metric summary row ── */
387
+ .metrics-row {
388
+ display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap;
389
+ }
390
+ .mbox {
391
+ background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 12px;
392
+ padding: 16px 20px; text-align: center; flex: 1; min-width: 100px;
393
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
394
+ transition: all 0.2s ease;
395
+ }
396
+ .mbox:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
397
+ .mbox .mv { font-size: 1.8rem; font-weight: 800; }
398
+ .mbox .ml { font-size: 0.75rem; color: #64748B; margin-top: 4px; font-weight: 500; }
399
+ .mbox.blue .mv { color: #2563EB; }
400
+ .mbox.red .mv { color: #EF4444; }
401
+ .mbox.yellow .mv { color: #F59E0B; }
402
+ .mbox.green .mv { color: #16A34A; }
403
+ .mbox.purple .mv { color: #7C3AED; }
404
+
405
  /* ── Job card ── */
406
  .job-card {
407
+ background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 12px;
408
+ padding: 16px 20px; margin-bottom: 10px;
409
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
410
+ transition: all 0.2s ease;
411
  }
412
+ .job-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
413
+ .job-card.jc-high { border-left: 4px solid #EF4444; }
414
+ .job-card.jc-medium { border-left: 4px solid #F59E0B; }
415
+ .job-card.jc-low { border-left: 4px solid #CBD5E1; }
416
  .jc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
417
+ .jc-title { font-size: 1rem; font-weight: 700; color: #0F172A; margin: 0; }
418
+ .jc-company { font-size: 0.85rem; color: #64748B; margin: 3px 0; }
419
  .jc-badge {
420
+ padding: 4px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: 700;
421
  white-space: nowrap; flex-shrink: 0;
422
  }
423
+ .badge-high { background: #FEF2F2; color: #EF4444; border: 1px solid #FECACA; }
424
+ .badge-medium { background: #FFFBEB; color: #D97706; border: 1px solid #FDE68A; }
425
+ .badge-low { background: #F8FAFC; color: #94A3B8; border: 1px solid #E2E8F0; }
426
+ .jc-meta {
427
+ display: flex; gap: 12px; margin-top: 10px;
428
+ align-items: center; flex-wrap: wrap;
 
 
 
 
 
 
 
 
 
429
  }
430
+ .jc-ats-before { color: #94A3B8; font-size: 0.8rem; }
431
+ .jc-ats-after { color: #16A34A; font-size: 0.85rem; font-weight: 700; }
432
+ .jc-ats-gain { color: #16A34A; font-size: 0.8rem; }
433
+ .jc-salary { color: #2563EB; font-size: 0.8rem; }
434
+ .jc-platform { color: #94A3B8; font-size: 0.78rem; }
 
 
435
 
436
+ /* ── History panel ── */
437
+ .history-panel {
438
+ background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 14px;
439
+ padding: 20px 24px; margin-bottom: 16px;
440
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
441
  }
442
+ .history-run {
443
+ background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 10px;
444
+ padding: 12px 16px; margin-bottom: 8px;
445
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
446
+ }
447
+ .history-run-meta { flex: 1; }
448
+ .history-run-date { font-size: 0.82rem; color: #64748B; font-weight: 500; }
449
+ .history-run-stats { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
450
+ .htag {
451
+ padding: 3px 10px; border-radius: 14px; font-size: 0.75rem; font-weight: 600;
452
+ background: #EFF6FF; color: #2563EB;
453
+ }
454
+ .htag-red { background: #FEF2F2; color: #EF4444; }
455
+ .htag-green { background: #F0FDF4; color: #16A34A; }
456
+
457
+ /* ── Welcome state ── */
458
+ .welcome-card {
459
+ background: #FFFFFF;
460
+ border: 1px solid #E2E8F0;
461
+ border-radius: 16px;
462
+ padding: 48px 32px;
463
+ text-align: center;
464
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
465
+ }
466
+ .welcome-icon { font-size: 3.5rem; margin-bottom: 16px; }
467
+ .welcome-title {
468
+ font-size: 1.3rem; font-weight: 700; color: #0F172A;
469
+ margin: 0 0 8px;
470
+ }
471
+ .welcome-desc {
472
+ font-size: 0.9rem; color: #64748B; max-width: 480px;
473
+ margin: 0 auto; line-height: 1.6;
474
  }
475
  </style>
476
  """, unsafe_allow_html=True)
 
498
  if _k not in st.session_state:
499
  st.session_state[_k] = _v
500
 
501
+ # ── Shared progress queue ────────────────────────────────────────────────────
502
  if "progress_q" not in st.session_state:
503
  st.session_state["progress_q"] = queue.Queue()
504
  _progress_q: queue.Queue = st.session_state["progress_q"]
505
 
506
 
507
+ # ── Pipeline steps definition ────────────────────────────────────────────────
508
  PIPELINE_STEPS = [
509
  {"id": "resume", "icon": "πŸ“„", "title": "Parse Resume"},
510
  {"id": "profile", "icon": "🧠", "title": "Build Profile"},
 
522
  _STEP_TITLE_MAP = {s["id"]: s["title"] for s in PIPELINE_STEPS}
523
 
524
 
525
+ # ══════════════════════════════════════════════════════════════════════════════
526
+ # HELPERS
527
+ # ══════════════════════════════════════════════════════════════════════════════
528
  def _score_color_cls(score):
529
  if score >= 8: return "high"
530
  if score >= 6: return "medium"
 
556
  <div class="step-card {css}">
557
  <span class="step-icon">{icon}</span>
558
  <div class="step-body">
559
+ <p class="step-title-run">{s['icon']} {s['title']}</p>
560
  <p class="step-detail">{safe or ('Waiting…' if status=='pending' else '')}</p>
561
  </div>
562
  <span class="step-time">{elapsed}</span>
 
594
  if ats_b is not None and ats_a is not None:
595
  ats_html = (
596
  f'<span class="jc-ats-before">ATS {ats_b}%</span>'
597
+ f'<span style="color:#CBD5E1">β†’</span>'
598
  f'<span class="jc-ats-after">{ats_a}%</span>'
599
  f'<span class="jc-ats-gain">(+{imp}pp)</span>'
600
  )
601
 
602
  sal_html = f'<span class="jc-salary">πŸ’° {salary}</span>' if salary and salary != "Not specified" else ""
603
+ link_html = f'<a href="{url}" target="_blank" style="color:#2563EB;font-size:0.8rem;text-decoration:none;font-weight:500">Apply β†’</a>' if url else ""
604
 
605
  t = title.replace("<","&lt;").replace(">","&gt;")
606
  co = company.replace("<","&lt;").replace(">","&gt;")
 
643
  </div>"""
644
 
645
 
 
646
  def _sheets_configured() -> tuple[bool, str]:
647
  if os.path.exists("google_credentials.json"):
648
+ return True, "Service account connected"
649
  if os.path.exists("google_token.json"):
650
+ return True, "OAuth connected"
651
  if os.path.exists("google_oauth_client.json"):
652
+ return False, "Needs one-time authorization"
653
+ return False, "Not connected yet"
654
+
655
+
656
+ def _readiness_score(has_resume, roles, locations, platforms, sheets_ok, min_score):
657
+ score = 0
658
+ total = 6
659
+ if has_resume: score += 1
660
+ if roles: score += 1
661
+ if locations: score += 1
662
+ if platforms: score += 1
663
+ if sheets_ok: score += 1
664
+ if min_score is not None: score += 1
665
+ return int((score / total) * 100)
666
 
667
+ def _readiness_level(pct):
668
+ if pct >= 100: return ("Automation Pro", True)
669
+ if pct >= 83: return ("Power Search Ready", False)
670
+ if pct >= 50: return ("Balanced Setup", False)
671
+ return ("Getting Started", False)
672
 
673
+
674
+ # ══════════════════════════════════════════════════════════════════════════════
675
  # HEADER
676
+ # ══════════════════════════════════════════════════════════════════════════════
677
+ has_resume = os.path.exists("data/resume/resume.pdf")
678
+ sheets_ok, sheets_status = _sheets_configured()
679
+
680
  hdr_l, hdr_r = st.columns([5, 1])
681
  with hdr_l:
682
+ status_text = "Running..." if st.session_state.running else (
683
+ "Results ready" if st.session_state.results else "Setup in progress"
684
+ )
685
+ st.html(f"""
686
  <div class="jaa-header">
687
  <div class="jaa-header-left">
688
  <p class="jaa-header-title">πŸ€– Job Automation Agent</p>
689
+ <p class="jaa-header-sub">AI-powered job discovery, resume matching, and application tracking</p>
690
  </div>
691
+ <span class="jaa-header-badge">{"⏳" if st.session_state.running else "✨"} {status_text}</span>
692
+ </div>""")
693
 
694
  with hdr_r:
695
  st.markdown("<br>", unsafe_allow_html=True)
 
699
  st.rerun()
700
 
701
 
702
+ # ══════════════════════════════════════════════════════════════════════════════
703
  # HISTORY PANEL
704
+ # ══════════════════════════════════════════════════════════════════════════════
705
  if st.session_state.show_history:
706
  from src.run_history import list_runs, load_run
707
  past_runs = list_runs()
708
 
709
+ st.html('<div class="history-panel">')
710
  st.markdown("### πŸ“œ Run History")
711
 
712
  if not past_runs:
 
749
  st.session_state.show_history = False
750
  st.rerun()
751
 
752
+ st.html('</div>')
753
 
754
  if st.session_state.loaded_run:
755
  st.info(f"πŸ“‚ Showing results from run: **{st.session_state.loaded_run}**")
 
757
  st.divider()
758
 
759
 
760
+ # ══════════════════════════════════════════════════════════════════════════════
761
+ # CONFIGURATION β€” Two-column layout
762
+ # ══════════════════════════════════════════════════════════════════════════════
763
+ show_config = not (st.session_state.running or st.session_state.results)
764
+
765
+ if show_config:
766
+ # ── Hero card ──
767
+ st.html("""
768
+ <div style="background:linear-gradient(135deg,#EFF6FF 0%,#F5F3FF 100%);
769
+ border:1px solid #DBEAFE; border-radius:14px;
770
+ padding:24px 28px; margin-bottom:20px;">
771
+ <div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px;">
772
+ <div>
773
+ <h3 style="margin:0; font-size:1.15rem; font-weight:700; color:#1E293B;">
774
+ Build your AI job search
775
+ </h3>
776
+ <p style="margin:4px 0 0; font-size:0.88rem; color:#64748B;">
777
+ Configure once. Let the agent discover, score, and track relevant jobs for you.
778
+ </p>
779
+ </div>
780
+ </div>
781
+ </div>""")
782
 
783
+ col_main, col_sidebar = st.columns([3, 1], gap="large")
784
+
785
+ with col_main:
786
+ # ────────────────────────────────────────────────────────────────────
787
+ # STEP 1: Upload Resume
788
+ # ────────────────────────────────────────────────────────────────────
789
+ step1_done = "done" if has_resume else ""
790
+ num1_cls = "done" if has_resume else ""
791
+ st.html(f"""
792
+ <div class="step-card-container {('completed' if has_resume else '')}">
793
+ <div class="step-card-header">
794
+ <div class="step-number {num1_cls}">{"βœ“" if has_resume else "1"}</div>
795
+ <p class="step-title-text">Upload your resume</p>
796
+ </div>
797
+ <p class="step-helper">We'll analyze your resume to match you with better-fit roles and generate ATS-optimized versions.</p>
798
+ </div>""")
799
 
800
+ resume_file = st.file_uploader(
801
+ "Drop your resume PDF here",
802
+ type=["pdf"], key="resume_upload",
803
+ label_visibility="collapsed",
804
+ help="Supported format: PDF. Max size: 10MB.",
805
+ )
806
  if resume_file:
807
  os.makedirs("data/resume", exist_ok=True)
808
  with open("data/resume/resume.pdf", "wb") as f:
809
  f.write(resume_file.read())
810
+ has_resume = True
811
+ st.rerun()
812
+
813
+ if has_resume:
814
  fsize = os.path.getsize("data/resume/resume.pdf") // 1024
815
+ st.html(f"""
816
+ <div class="micro-success">
817
+ βœ… <strong>resume.pdf</strong> uploaded ({fsize} KB) β€” Ready for AI matching
818
+ </div>""")
819
  else:
820
+ st.caption("Upload your resume to unlock AI matching.")
821
 
822
+ # ────────────────────────────────────────────────────────────────────
823
+ # STEP 2: Target Roles
824
+ # ────────────────────────────────────────────────────────────────────
825
  roles = st.multiselect(
826
+ "**Step 2 Β· Target roles** β€” Select up to 5 roles for more focused results",
827
  options=[
828
  "Product Manager", "Senior Product Manager", "AI Product Manager",
829
  "Technical Product Manager", "Data Product Manager", "Platform Product Manager",
 
831
  "Associate Product Manager", "Head of Product",
832
  ],
833
  default=["Product Manager", "Senior Product Manager", "AI Product Manager"],
834
+ key="roles_select",
835
  )
836
+ if roles:
837
+ st.html(f'<div class="micro-success">🎯 Great focus β€” {len(roles)} target role{"s" if len(roles)!=1 else ""} selected</div>')
838
 
839
+ # ────────────────────────────────────────────────────────────────────
840
+ # STEP 3: Locations
841
+ # ─────────────────────���──────────────────────────────────────────────
 
 
 
 
842
  locations = st.multiselect(
843
+ "**Step 3 Β· Where should we search?** β€” Mix cities, countries, and remote preferences",
844
  options=["India", "Bangalore", "Hyderabad", "Mumbai", "Delhi NCR",
845
  "Pune", "Chennai", "Noida", "Remote", "Worldwide"],
846
  default=["India", "Bangalore"],
847
+ key="locations_select",
848
  )
849
+ if locations:
850
+ st.html(f'<div class="micro-success">πŸ“ Searching in {len(locations)} location{"s" if len(locations)!=1 else ""}</div>')
851
+
852
+ # ────────────────────────────────────────────────────────────────────
853
+ # STEP 4: Job Freshness & Volume
854
+ # ────────────────────────────────────────────────────────────────────
855
+ st.html("""
856
+ <div class="step-card-container">
857
+ <div class="step-card-header">
858
+ <div class="step-number">4</div>
859
+ <p class="step-title-text">Job freshness and search volume</p>
860
+ </div>
861
+ <p class="step-helper">Lower values make results more focused. Higher values increase coverage.</p>
862
+ </div>""")
863
+ fc1, fc2 = st.columns(2)
864
+ with fc1:
865
+ days_posted = st.selectbox(
866
+ "Only show jobs posted within",
867
+ options=[3, 7, 14, 30],
868
+ index=1,
869
+ format_func=lambda x: f"Last {x} days",
870
+ )
871
+ with fc2:
872
+ max_jobs = st.number_input(
873
+ "Maximum jobs per platform",
874
+ min_value=5, max_value=100, value=25, step=5,
875
+ help="Total jobs to fetch from each platform (not per query)",
876
+ )
877
 
878
+ # ────────────────────────────────────────────────────────────────────
879
+ # STEP 5: Job Platforms
880
+ # ────────────────────────────────────────────────────────────────────
881
+ from src.ever_jobs_bridge.platforms import PLATFORM_GROUPS, INDIA_DEFAULT_PLATFORMS, EVER_JOBS_PLATFORMS
882
+
883
+ sb_count = len(PLATFORM_GROUPS["Search Boards"])
884
+ ats_count = len(PLATFORM_GROUPS["ATS Platforms"])
885
+ cp_count = len(PLATFORM_GROUPS["Company Pages"])
886
+ total_platforms = sb_count + ats_count + cp_count
887
+
888
+ st.html(f"""
889
+ <div class="step-card-container">
890
+ <div class="step-card-header">
891
+ <div class="step-number">5</div>
892
+ <p class="step-title-text">Job platforms</p>
893
+ </div>
894
+ <p class="step-helper">Select platforms where the agent should search. {total_platforms} platforms available across 3 categories.</p>
895
+ </div>""")
896
 
897
+ ej_col1, ej_col2, ej_col3 = st.columns(3)
898
+
899
+ with ej_col1:
900
+ with st.expander(f"πŸ” Search Boards ({sb_count})"):
901
+ sb_options = PLATFORM_GROUPS["Search Boards"]
902
+ sb_defaults = [p for p in INDIA_DEFAULT_PLATFORMS if p in sb_options]
903
+ if st.button("↻ Reset to recommended", key="sb_rec", use_container_width=True):
904
+ st.session_state["ej_search_boards"] = sb_defaults
905
+ st.rerun()
906
+ selected_search_boards = st.multiselect(
907
+ "Search Boards",
908
+ options=sb_options,
909
+ default=sb_defaults,
910
+ format_func=lambda k: EVER_JOBS_PLATFORMS.get(k, {}).get("display", k),
911
+ label_visibility="collapsed",
912
+ key="ej_search_boards",
913
+ )
914
 
915
+ with ej_col2:
916
+ with st.expander(f"🏒 ATS Platforms ({ats_count})"):
917
+ ats_options = PLATFORM_GROUPS["ATS Platforms"]
918
+ selected_ats = st.multiselect(
919
+ "ATS Platforms",
920
+ options=ats_options,
921
+ default=[],
922
+ format_func=lambda k: EVER_JOBS_PLATFORMS.get(k, {}).get("display", k),
923
+ label_visibility="collapsed",
924
+ key="ej_ats_platforms",
925
+ help="Applicant Tracking System platforms (Greenhouse, Lever, Workday, etc.). These are job POSTING platforms, not resume scorers.",
926
+ )
927
 
928
+ with ej_col3:
929
+ with st.expander(f"🏭 Company Pages ({cp_count})"):
930
+ cp_options = PLATFORM_GROUPS["Company Pages"]
931
+ selected_company = st.multiselect(
932
+ "Company Pages",
933
+ options=cp_options,
934
+ default=[],
935
+ format_func=lambda k: EVER_JOBS_PLATFORMS.get(k, {}).get("display", k),
936
+ label_visibility="collapsed",
937
+ key="ej_company_pages",
938
+ help="Direct company career page scrapers. Many are US-centric and slower than search boards.",
939
+ )
940
+
941
+ ever_jobs_platforms = selected_search_boards + selected_ats + selected_company
942
+ use_ever_jobs = len(ever_jobs_platforms) > 0
 
 
 
943
 
944
+ if len(ever_jobs_platforms) > 30:
945
+ st.warning(
946
+ f"⚠ **{len(ever_jobs_platforms)} platforms selected.** "
947
+ "Runs with >30 platforms may take 5–10 minutes. "
948
+ "Consider reducing to your top platforms for faster results.",
949
+ icon="⚠️",
 
 
 
 
 
950
  )
951
+ elif use_ever_jobs:
952
+ st.html(f'<div class="micro-success">🌐 {len(ever_jobs_platforms)} platform{"s" if len(ever_jobs_platforms)!=1 else ""} selected</div>')
953
+ else:
954
+ st.caption("Select platforms where the agent should search.")
955
+
956
+ # ────────────────────────────────────────────────────────────────────
957
+ # STEP 6: AI Match Score
958
+ # ────────────────────────────────────────────────────────────────────
959
+ st.html("""
960
+ <div class="step-card-container">
961
+ <div class="step-card-header">
962
+ <div class="step-number">6</div>
963
+ <p class="step-title-text">AI match score threshold</p>
964
+ </div>
965
+ <p class="step-helper">Jobs scoring below this get a basic template resume. Jobs above get a fully AI-tailored ATS-optimized version.</p>
966
+ </div>""")
967
 
968
+ sc1, sc2 = st.columns([2, 1])
969
+ with sc1:
970
+ min_score = st.slider(
971
+ "Minimum AI match score",
972
+ 1, 10, 1,
973
+ help="Set to 1 to generate LLM resumes for ALL jobs. Set higher for more focused tailoring.",
 
 
 
 
 
974
  )
975
+ labels = {1: "Broad β€” all jobs", 4: "Balanced", 7: "Focused", 10: "Highly targeted"}
976
+ nearest = min(labels.keys(), key=lambda k: abs(k - min_score))
977
+ st.caption(f"Mode: **{labels[nearest]}** β€” Score {min_score}/10")
978
+ with sc2:
979
+ est_jobs = max_jobs * max(1, len(ever_jobs_platforms))
980
+ est_time = max(2, est_jobs // 50)
981
+ st.html(f"""
982
+ <div style="background:#F8FAFC; border:1px solid #E2E8F0; border-radius:10px; padding:14px; text-align:center;">
983
+ <div style="font-size:0.78rem; color:#64748B; margin-bottom:4px;">Estimated scan</div>
984
+ <div style="font-size:1.3rem; font-weight:700; color:#2563EB;">~{min(est_jobs, 500)} jobs</div>
985
+ <div style="font-size:0.75rem; color:#94A3B8; margin-top:2px;">~{est_time}–{est_time*2} minutes</div>
986
+ </div>""")
987
 
988
+ # ────────────────────────────────────────────────────────────────────
989
+ # STEP 7: Google Sheet Connection
990
+ # ────────────────────────────────────────────────────────────────────
991
+ gs_status_cls = "completed" if sheets_ok else ""
992
+ gs_num_cls = "done" if sheets_ok else ""
993
+ st.html(f"""
994
+ <div class="step-card-container {gs_status_cls}">
995
+ <div class="step-card-header">
996
+ <div class="step-number {gs_num_cls}">{"βœ“" if sheets_ok else "7"}</div>
997
+ <p class="step-title-text">Application tracker</p>
998
+ </div>
999
+ <p class="step-helper">Save all discovered jobs into a Google Sheet for easy tracking and sharing.</p>
1000
+ </div>""")
1001
 
1002
+ if sheets_ok:
1003
+ st.html(f'<div class="micro-success">βœ… {sheets_status}</div>')
1004
+ from config import GOOGLE as _G
1005
+ st.caption(f"Sheet: `{_G['sheet_id'][:20]}…` Β· Tab: `{_G['sheet_tab']}`")
1006
+ else:
1007
+ st.html(f"""
1008
+ <div style="background:#FFFBEB; border:1px solid #FDE68A; border-radius:10px; padding:12px 16px; margin:8px 0;">
1009
+ <span style="font-size:0.85rem; color:#92400E;">
1010
+ ⚠ {sheets_status}. Results will be saved locally.
1011
+ </span>
1012
+ </div>""")
1013
+ with st.expander("πŸ”§ Advanced setup"):
1014
+ st.markdown("""
1015
+ 1. Create a Google service account at [console.cloud.google.com](https://console.cloud.google.com)
1016
+ 2. Download the JSON credentials file
1017
+ 3. Save as `google_credentials.json` in the project root
1018
+ 4. Share your Google Sheet with the service account email
1019
+
1020
+ Or run `python connect_google.py` for OAuth-based setup.
1021
+ """)
1022
+
1023
+ # ────────────────────────────────────────────────────────────────────
1024
+ # RIGHT SIDEBAR β€” Run Readiness Panel
1025
+ # ────────────────────────────────────────────────────────────────────
1026
+ with col_sidebar:
1027
+ readiness = _readiness_score(has_resume, roles, locations, ever_jobs_platforms, sheets_ok, min_score)
1028
+ level_name, is_gold = _readiness_level(readiness)
1029
+
1030
+ st.html(f"""
1031
+ <div class="readiness-panel">
1032
+ <p class="readiness-title">Run Readiness</p>
1033
+ <p class="readiness-score">{readiness}%</p>
1034
+ <p class="readiness-label">Setup completeness</p>
1035
+ <div class="readiness-badge {"gold" if is_gold else ""}">{level_name}</div>
1036
+ </div>""")
1037
 
1038
+ # Checklist
1039
+ checks = [
1040
+ (has_resume, "Resume uploaded"),
1041
+ (bool(roles), f"{len(roles)} role{'s' if len(roles)!=1 else ''} selected" if roles else "Select target roles"),
1042
+ (bool(locations), f"{len(locations)} location{'s' if len(locations)!=1 else ''} set" if locations else "Choose locations"),
1043
+ (bool(ever_jobs_platforms), f"{len(ever_jobs_platforms)} platform{'s' if len(ever_jobs_platforms)!=1 else ''} active" if ever_jobs_platforms else "Select platforms"),
1044
+ (sheets_ok, "Tracker connected" if sheets_ok else "Connect Google Sheet"),
1045
+ (min_score is not None, f"Match score: {min_score}/10"),
1046
+ ]
1047
+
1048
+ checklist_html = ""
1049
+ for done, label in checks:
1050
+ icon_cls = "check-done" if done else "check-pending"
1051
+ icon_txt = "βœ“" if done else ""
1052
+ label_cls = "done" if done else "pending"
1053
+ checklist_html += f"""
1054
+ <div class="checklist-item">
1055
+ <div class="{icon_cls}">{icon_txt}</div>
1056
+ <span class="check-label {label_cls}">{label}</span>
1057
+ </div>"""
1058
 
1059
+ pending = sum(1 for d, _ in checks if not d)
1060
+ if pending > 0:
1061
+ cta_copy = f"Complete {pending} more step{'s' if pending!=1 else ''} to unlock a better search."
1062
+ else:
1063
+ cta_copy = "You're all set! Launch your search."
1064
+
1065
+ st.html(f"""
1066
+ <div style="background:#FFFFFF; border:1px solid #E2E8F0; border-radius:14px; padding:16px; margin-top:12px;
1067
+ box-shadow:0 1px 3px rgba(0,0,0,0.04);">
1068
+ {checklist_html}
1069
+ <p style="font-size:0.8rem; color:#64748B; margin:12px 0 0; text-align:center; font-weight:500;">
1070
+ {cta_copy}
1071
+ </p>
1072
+ </div>""")
1073
 
1074
+ # Summary
1075
+ st.html(f"""
1076
+ <div style="background:#FFFFFF; border:1px solid #E2E8F0; border-radius:14px; padding:16px; margin-top:12px;
1077
+ box-shadow:0 1px 3px rgba(0,0,0,0.04);">
1078
+ <p style="font-size:0.85rem; font-weight:700; color:#0F172A; margin:0 0 10px;">Summary</p>
1079
+ <div class="summary-row"><span class="summary-key">Roles</span><span class="summary-val">{len(roles)}</span></div>
1080
+ <div class="summary-row"><span class="summary-key">Locations</span><span class="summary-val">{len(locations)}</span></div>
1081
+ <div class="summary-row"><span class="summary-key">Platforms</span><span class="summary-val">{len(ever_jobs_platforms)}</span></div>
1082
+ <div class="summary-row"><span class="summary-key">Freshness</span><span class="summary-val">Last {days_posted} days</span></div>
1083
+ <div class="summary-row"><span class="summary-key">Max / platform</span><span class="summary-val">{max_jobs}</span></div>
1084
+ <div class="summary-row"><span class="summary-key">AI match min</span><span class="summary-val">{min_score}/10</span></div>
1085
+ </div>""")
1086
 
1087
+ # Achievement badges
1088
+ badges_html = ""
1089
+ badge_defs = [
1090
+ (has_resume, "πŸ“„ Resume Ready"),
1091
+ (len(roles) >= 2, "🎯 Role Focused"),
1092
+ (len(ever_jobs_platforms) >= 5, "🌐 Platform Explorer"),
1093
+ (sheets_ok, "πŸ“Š Tracker Connected"),
1094
+ (readiness >= 100, "⚑ Power Search"),
1095
+ ]
1096
+ for earned, label in badge_defs:
1097
+ cls = "badge-earned" if earned else "badge-locked"
1098
+ badges_html += f'<span class="achievement-badge {cls}">{label}</span>'
1099
+
1100
+ st.html(f"""
1101
+ <div style="background:#FFFFFF; border:1px solid #E2E8F0; border-radius:14px; padding:16px; margin-top:12px;
1102
+ box-shadow:0 1px 3px rgba(0,0,0,0.04);">
1103
+ <p style="font-size:0.85rem; font-weight:700; color:#0F172A; margin:0 0 10px;">Achievements</p>
1104
+ <div class="badge-row">{badges_html}</div>
1105
+ </div>""")
1106
+
1107
+ # Start Search CTA
1108
+ st.markdown("---")
1109
+ can_start = has_resume and bool(roles) and bool(locations) and bool(ever_jobs_platforms)
1110
+ start = st.button(
1111
+ "πŸš€ Start AI Job Search" if not st.session_state.running else "⏳ Running…",
1112
+ disabled=st.session_state.running or not can_start,
1113
+ use_container_width=True,
1114
+ type="primary",
1115
+ key="start_btn",
1116
+ )
1117
+ if not can_start:
1118
+ missing = []
1119
+ if not has_resume: missing.append("resume")
1120
+ if not roles: missing.append("roles")
1121
+ if not locations: missing.append("locations")
1122
+ if not ever_jobs_platforms: missing.append("platforms")
1123
+ st.caption(f"Missing: {', '.join(missing)}")
1124
 
1125
+ else:
1126
+ # When running or showing results, we still need these variables
1127
+ # They won't be used for starting a new run, but the pipeline needs them defined
1128
+ roles = st.session_state.get("_last_roles", [])
1129
+ locations = st.session_state.get("_last_locations", [])
1130
+ days_posted = st.session_state.get("_last_days", 7)
1131
+ max_jobs = st.session_state.get("_last_max_jobs", 25)
1132
+ min_score = st.session_state.get("_last_min_score", 1)
1133
+ ever_jobs_platforms = st.session_state.get("_last_platforms", [])
1134
+ start = False
1135
+
1136
+
1137
+ # ══════════════════════════════════════════════════════════════════════════════
1138
+ # START BUTTON (also shown at top of results for re-running)
1139
+ # ══════════════════════════════════════════════════════════════════════════════
1140
+ if not show_config and not st.session_state.running and st.session_state.results:
1141
+ with st.columns([1, 2, 1])[1]:
1142
+ st.markdown('<div class="secondary-btn">', unsafe_allow_html=True)
1143
+ if st.button("πŸ”„ New Search", use_container_width=True, key="new_search_btn"):
1144
+ st.session_state.results = None
1145
+ st.session_state.loaded_run = ""
1146
+ st.session_state.progress_pct = 0
1147
+ st.session_state.steps = {}
1148
+ st.rerun()
1149
+ st.markdown('</div>', unsafe_allow_html=True)
1150
+
1151
+ # Progress placeholders (always declared)
1152
  progress_placeholder = st.empty()
1153
  steps_placeholder = st.empty()
1154
  log_placeholder = st.empty()
1155
  done_placeholder = st.empty()
1156
 
1157
 
1158
+ # ══════════════════════════════════════════════════════════════════════════════
1159
  # LAUNCH PIPELINE
1160
+ # ══════════════════════════════════════════════════════════════════════════════
1161
+ if show_config and start and not st.session_state.running:
1162
  if not os.path.exists("data/resume/resume.pdf"):
1163
+ st.error("Please upload your resume first.")
1164
  elif not roles:
1165
+ st.error("Please select at least one target role.")
1166
  elif not locations:
1167
  st.error("Please select at least one location.")
1168
+ elif not ever_jobs_platforms:
1169
+ st.error("Please select at least one platform.")
1170
  else:
1171
+ # Save config to session state for display during run
1172
+ st.session_state["_last_roles"] = roles
1173
+ st.session_state["_last_locations"] = locations
1174
+ st.session_state["_last_days"] = days_posted
1175
+ st.session_state["_last_max_jobs"] = max_jobs
1176
+ st.session_state["_last_min_score"] = min_score
1177
+ st.session_state["_last_platforms"] = ever_jobs_platforms
1178
+
1179
  st.session_state.running = True
1180
  st.session_state.results = None
1181
  st.session_state.loaded_run = ""
 
1202
  _platforms=platforms_cfg, _jscfg=job_search_cfg, _ocfg=output_cfg,
1203
  _q=_progress_q):
1204
  import time as _t, traceback as _tb
 
1205
  _progress_q = _q
1206
  def _q_log(msg): _q.put(("log", msg))
1207
  def _q_progress(pct, lbl=""): _q.put(("progress", pct, lbl))
 
1340
  else:
1341
  _step_skip("naukri")
1342
 
 
1343
  _ej_platforms = [p for p in _all_plats if p not in _legacy_keys]
1344
  if _ej_platforms:
1345
  from src.scrapers.ever_jobs import EverJobsScraper
 
1363
  if outer_done:
1364
  break
1365
  for loc in _jscfg["locations"]:
 
1366
  if len(platform_jobs) >= MAX_PER_PLAT:
1367
  outer_done = True
1368
  break
 
1396
  _q.put(("step", plat_id, "active",
1397
  f"Role {ri+1}/{n_roles} β€” {len(platform_jobs)} jobs so far", ""))
1398
 
 
1399
  needs_desc = [j for j in platform_jobs if not j.description]
1400
  if needs_desc and hasattr(scraper, "get_details_bulk"):
1401
  _q.put(("step", plat_id, "active",
 
1438
  _q_progress(78, "Assessment complete!")
1439
  _q_log(f"βœ… Assessment done β€” High: {high}, Good: {med}, Low: {low}")
1440
 
1441
+ # ── Generate resumes ──
1442
  t0 = _t.time()
1443
  llm_elig = sum(1 for j in assessed_jobs if j.get("relevance_score",0) >= _min_score)
1444
  phase2_cfgs = [m for m in ASSESSMENT_MODELS if m.get("phase2") and m.get("api_key")]
 
1457
  assessed_jobs = customizer.customize_for_jobs(
1458
  assessed_jobs,
1459
  min_score_for_llm=_min_score,
1460
+ max_llm_resumes=len(assessed_jobs),
1461
  generate_all=True,
1462
  model_cfgs=phase2_cfgs,
1463
  progress_cb=_resume_cb,
 
1506
  _q_log(f"βœ… Excel: {excel_path}")
1507
  _q_log(f"βœ… Resumes folder: {_ocfg['resumes_dir']}")
1508
 
 
1509
  try:
1510
  from src.run_history import save_run
1511
  save_run(assessed_jobs, {
 
1533
  st.rerun()
1534
 
1535
 
1536
+ # ── Drain progress queue ─────────────────────────────────────────────────────
1537
  if st.session_state.running:
1538
  while True:
1539
  try:
 
1564
  break
1565
 
1566
 
1567
+ # ── Render progress ──────────────────────────────────────────────────────────
1568
  if st.session_state.running or (st.session_state.progress_pct and st.session_state.results is None):
1569
  pct = st.session_state.progress_pct
1570
  lbl = st.session_state.progress_label
 
1576
 
1577
  if st.session_state.log_msgs:
1578
  log_placeholder.html(
1579
+ '<p style="font-weight:700;color:#0F172A;margin:0 0 4px 0">Live Log</p>' +
1580
  _render_log(st.session_state.log_msgs)
1581
  )
1582
 
1583
+ # ── Done banner ──────────────────────────────────────────────────────────────
1584
  if not st.session_state.running and st.session_state.results:
1585
  n = len(st.session_state.results)
1586
  high = sum(1 for j in st.session_state.results if j.get("relevance_score", 0) >= 8)
1587
  pdfs = sum(1 for j in st.session_state.results if j.get("resume_pdf_path"))
1588
  hist_note = f" Β· Saved to history" if st.session_state.loaded_run else ""
1589
  done_placeholder.success(
1590
+ f"βœ… **Done!** Found **{n} jobs** Β· **{high} high priority** Β· **{pdfs} PDFs ready**{hist_note}"
 
1591
  )
1592
 
 
1593
  if st.session_state.running:
1594
  time.sleep(0.8)
1595
  st.rerun()
1596
 
1597
 
1598
+ # ══════════════════════════════════════════════════════════════════════════════
1599
+ # RESULTS TABS
1600
+ # ══════════════════════════════════════════════════════════════════════════════
1601
  results = st.session_state.results
1602
 
1603
  if results is not None:
 
1610
  # TAB 1 β€” RESULTS
1611
  # ══════════════════════════════════════════════════════════════════════════
1612
  with tab_results:
 
1613
  st.html(_metrics_html(results))
1614
 
 
1615
  dl1, dl2, dl3 = st.columns(3)
1616
  with dl1:
1617
  xp = st.session_state.excel_path
 
1622
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1623
  use_container_width=True)
1624
  with dl2:
 
1625
  resume_base = Path("data/output/resumes")
1626
  if st.session_state.loaded_run:
 
1627
  first_res = next((j.get("resume_path","") for j in results if j.get("resume_path")), "")
1628
  run_folder = Path(first_res).parent if first_res else None
1629
  else:
 
1630
  date_dirs = sorted(
1631
  [d for d in resume_base.iterdir() if d.is_dir()],
1632
  key=lambda d: d.stat().st_mtime, reverse=True,
 
1647
  n_pdf = len(pdf_f)
1648
  n_docx = len(docx_f)
1649
  st.download_button(
1650
+ f"⬇ Resumes ({n_docx} DOCX + {n_pdf} PDF)",
1651
  zbuf.read(), "tailored_resumes.zip", "application/zip",
1652
  use_container_width=True,
1653
  )
1654
+ st.caption(f"πŸ“ {run_folder}")
1655
  with dl3:
1656
  if sheets_ok:
1657
  from config import GOOGLE as _GC
 
1660
 
1661
  st.divider()
1662
 
 
1663
  col_f1, col_f2, col_f3 = st.columns(3)
1664
  with col_f1:
1665
  f_score = st.slider("Min score", 1, 10, 1, key="f_score")
 
1683
  st.caption(f"Showing **{len(filtered)}** of **{n_res}** jobs")
1684
 
1685
  if view_mode.startswith("πŸ“‹"):
 
1686
  for rank, job in enumerate(filtered[:10], 1):
1687
  st.html(_job_card_html(job, rank))
1688
  if len(filtered) > 10:
1689
  st.caption(f"… and {len(filtered)-10} more. Switch to Table view to see all.")
1690
  else:
 
1691
  if not filtered:
1692
  st.info("No jobs match the current filters.")
1693
  else:
 
2016
  colored += f'<span style="color:#d1d5db">{safe}</span>'
2017
 
2018
  st.markdown(
2019
+ f'<div style="background:#1E293B;border:1px solid #334155;border-radius:10px;'
2020
+ f'padding:14px;font-family:JetBrains Mono,Courier New,monospace;font-size:0.78rem;'
2021
  f'max-height:500px;overflow-y:auto;white-space:pre-wrap">'
2022
  f'{colored}</div>',
2023
  unsafe_allow_html=True,
 
2040
  st.rerun()
2041
 
2042
  else:
2043
+ # No results yet and not in config mode (shouldn't happen, but safety net)
2044
+ if not show_config:
2045
+ st.html("""
2046
+ <div class="welcome-card">
2047
+ <div class="welcome-icon">πŸ€–</div>
2048
+ <p class="welcome-title">Ready to find your next PM role</p>
2049
+ <p class="welcome-desc">
2050
+ Configure your search settings, then click <strong>Start AI Job Search</strong>.<br>
2051
+ The agent will scrape multiple platforms, assess every job with AI models,
2052
  and generate ATS-optimized resumes for all matches.
2053
  </p>
2054
+ </div>""")