Compare commits

..

13 commits

Author SHA1 Message Date
d60f05ec17 chore: release v0.8.6 — resume review modal + resume manager
Some checks failed
CI / Backend (Python) (push) Failing after 42s
CI / Frontend (Vue) (push) Successful in 26s
Mirror / mirror (push) Failing after 9s
Release / release (push) Failing after 5s
2026-04-12 12:26:46 -07:00
f22e713968 fix: review issues — import size limit, aria labels, CSS vars, Set reactivity 2026-04-12 11:52:23 -07:00
35cb99f99c feat: resume review modal + resume manager complete 2026-04-12 11:46:18 -07:00
1ada92f7d7 feat: add Resumes route and nav link
Add /resumes route (lazy-loaded ResumesView) to router and add
DocumentTextIcon Resumes entry to AppNav sidebar navLinks after Apply.
2026-04-12 11:36:13 -07:00
8245333c9c feat: add ResumeLibraryCard to Apply workspace 2026-04-12 11:35:06 -07:00
d4a2107411 feat: add ResumesView standalone resume library manager 2026-04-12 11:32:29 -07:00
f7b719f854 feat: replace inline review with ResumeReviewModal; add save-to-library on approve 2026-04-12 11:29:03 -07:00
a3aaed0e0c feat: add ResumeReviewModal with paged tab navigation and color-coded status 2026-04-12 11:18:01 -07:00
1253ef15a0 chore: deploy resume library endpoints to cloud 2026-04-12 11:12:02 -07:00
ae7549c2c9 feat: add resume library and per-job resume API endpoints
- POST/GET /api/resumes — create and list resumes
- POST /api/resumes/import — import from .txt/.pdf/.docx/.odt/.yaml
- GET/PATCH/DELETE /api/resumes/{id} — CRUD for individual resumes
- POST /api/resumes/{id}/set-default — set default resume
- GET/PATCH /api/jobs/{job_id}/resume — per-job resume association
- Extend approve_resume to optionally save to resume library (save_to_library + resume_name body fields)
- 9 passing tests in tests/test_resumes_api.py
2026-04-12 10:42:38 -07:00
365eff1506 feat: add resume library CRUD helpers to db.py 2026-04-12 10:39:32 -07:00
6e73bfc48a feat: add resumes table and jobs.resume_id column (migration 005) 2026-04-12 10:36:57 -07:00
70d1543a65 fix: make sync section dynamic based on configured integrations
Replaced stale module-level _NOTION_CONNECTED flag (evaluated once at
import time) with live _notion_configured() calls so the dashboard
reflects the actual integration state on each render.

- Sync section subheader/button: "Send to Notion" only when Notion is
  configured; otherwise shows "Set up a sync integration" prompt
- Caption and metric label drop "to Notion" when no integration is set
- Closes #16
2026-04-06 10:08:21 -07:00
18 changed files with 2697 additions and 71 deletions

View file

@ -9,6 +9,38 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
## [0.8.6] — 2026-04-12
### Added
- **Resume Review Modal** — paged tabbed dialog replaces the inline resume review
section in the Apply workspace. Pages through Skills diff, Summary diff, one page
per experience entry, and a Confirm summary. Color-coded tab status: unvisited
(gray), in-progress (indigo), accepted (green), partial (amber), skipped (slate).
Full ARIA tabs pattern with focus trap and `Teleport to body`.
- **Resume Library** — new `/resumes` page for managing saved resumes. Two-column
layout: list sidebar + full-text preview pane. Supports import (.txt, .pdf, .docx,
.odt, .yaml), rename (Edit), set as default, download (txt/pdf/yaml), and delete
(guarded: disabled when only resume or is default). 5 MB upload limit.
- **ResumeLibraryCard** — compact widget shown above the ATS Resume Optimizer in the
Apply workspace. Displays the currently active resume for the job (job-specific or
global default), with Switch and Manage deep links.
- **Resume library API**`GET/POST /api/resumes`, `GET/PATCH/DELETE /api/resumes/{id}`,
`POST /api/resumes/{id}/set-default`, `POST /api/resumes/import`,
`GET/PATCH /api/jobs/{job_id}/resume`. `approve_resume` extended with
`save_to_library` + `resume_name` params to save optimized resumes directly.
- **`resumes` DB migration** — `migrations/005_resumes_table.sql` adds `resumes` table
(10 columns) and `resume_id` FK on `jobs`.
- **Resumes nav link** — Document icon entry added after Apply in the main nav.
### Changed
- Resume optimizer "Awaiting review" state now triggers the Review Modal instead of
rendering an inline diff; save-to-library checkbox and name input surfaced on the
preview confirmation step.
---
## [0.8.5] — 2026-04-02
### Added

View file

@ -25,7 +25,6 @@ from scripts.task_runner import submit_task
from app.cloud_session import resolve_session, get_db_path
_CONFIG_DIR = Path(__file__).parent.parent / "config"
_NOTION_CONNECTED = (_CONFIG_DIR / "integrations" / "notion.yaml").exists()
resolve_session("peregrine")
init_db(get_db_path())
@ -134,7 +133,7 @@ def _queue_url_imports(db_path: Path, urls: list) -> int:
st.title(f"🔍 {_name}'s Job Search")
st.caption("Discover → Review → Sync to Notion")
st.caption("Discover → Review → Sync" + (" to Notion" if _notion_configured() else ""))
st.divider()
@ -146,7 +145,7 @@ def _live_counts():
col1.metric("Pending Review", counts.get("pending", 0))
col2.metric("Approved", counts.get("approved", 0))
col3.metric("Applied", counts.get("applied", 0))
col4.metric("Synced to Notion", counts.get("synced", 0))
col4.metric("Synced" + (" to Notion" if _notion_configured() else ""), counts.get("synced", 0))
col5.metric("Rejected", counts.get("rejected", 0))
@ -237,7 +236,7 @@ with mid:
with right:
approved_count = get_job_counts(get_db_path()).get("approved", 0)
if _NOTION_CONNECTED:
if _notion_configured():
st.subheader("Send to Notion")
st.caption("Push all approved jobs to your Notion tracking database.")
if approved_count == 0:

View file

@ -0,0 +1,17 @@
-- 005_resumes_table.sql
-- Resume library: named saved resumes per user (optimizer output, imports, manual)
CREATE TABLE IF NOT EXISTS resumes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'manual',
job_id INTEGER REFERENCES jobs(id),
text TEXT NOT NULL,
struct_json TEXT,
word_count INTEGER,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
ALTER TABLE jobs ADD COLUMN resume_id INTEGER REFERENCES resumes(id);

View file

@ -345,6 +345,96 @@ def get_optimized_resume(db_path: Path = DEFAULT_DB, job_id: int = None) -> dict
}
def save_resume_draft(db_path: Path = DEFAULT_DB, job_id: int = None,
draft_json: str = "") -> None:
"""Persist a structured resume review draft (awaiting user approval)."""
if job_id is None:
return
conn = sqlite3.connect(db_path)
conn.execute(
"UPDATE jobs SET resume_draft_json = ? WHERE id = ?",
(draft_json or None, job_id),
)
conn.commit()
conn.close()
def get_resume_draft(db_path: Path = DEFAULT_DB, job_id: int = None) -> dict | None:
"""Return the pending review draft, or None if no draft is waiting."""
if job_id is None:
return None
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT resume_draft_json FROM jobs WHERE id = ?", (job_id,)
).fetchone()
conn.close()
if not row or not row["resume_draft_json"]:
return None
import json
try:
return json.loads(row["resume_draft_json"])
except Exception:
return None
def finalize_resume(db_path: Path = DEFAULT_DB, job_id: int = None,
final_text: str = "") -> None:
"""Save approved resume text, archive the previous version, and clear draft."""
if job_id is None:
return
import json
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT optimized_resume, resume_archive_json FROM jobs WHERE id = ?", (job_id,)
).fetchone()
conn.row_factory = None
# Archive current finalized version if present
archive: list = []
if row:
if row["resume_archive_json"]:
try:
archive = json.loads(row["resume_archive_json"])
except Exception:
archive = []
if row["optimized_resume"]:
from datetime import datetime
archive.append({
"archived_at": datetime.now().isoformat()[:16],
"text": row["optimized_resume"],
})
conn.execute(
"UPDATE jobs SET optimized_resume = ?, resume_draft_json = NULL, "
"resume_archive_json = ? WHERE id = ?",
(final_text, json.dumps(archive), job_id),
)
conn.commit()
conn.close()
def get_resume_archive(db_path: Path = DEFAULT_DB, job_id: int = None) -> list:
"""Return list of past finalized resume versions (newest archived first)."""
if job_id is None:
return []
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT resume_archive_json FROM jobs WHERE id = ?", (job_id,)
).fetchone()
conn.close()
if not row or not row["resume_archive_json"]:
return []
import json
try:
entries = json.loads(row["resume_archive_json"])
return list(reversed(entries)) # newest first
except Exception:
return []
_UPDATABLE_JOB_COLS = {
"title", "company", "url", "source", "location", "is_remote",
"salary", "description", "match_score", "keyword_gaps",
@ -831,3 +921,151 @@ def get_task_for_job(db_path: Path = DEFAULT_DB, task_type: str = "",
).fetchone()
conn.close()
return dict(row) if row else None
# ── Resume library helpers ────────────────────────────────────────────────────
def _resume_as_dict(row) -> dict:
"""Convert a sqlite3.Row from the resumes table to a plain dict."""
return {
"id": row["id"],
"name": row["name"],
"source": row["source"],
"job_id": row["job_id"],
"text": row["text"],
"struct_json": row["struct_json"],
"word_count": row["word_count"],
"is_default": row["is_default"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def create_resume(
db_path: Path = DEFAULT_DB,
name: str = "",
text: str = "",
source: str = "manual",
job_id: int | None = None,
struct_json: str | None = None,
) -> dict:
"""Insert a new resume into the library. Returns the created row as a dict."""
word_count = len(text.split()) if text else 0
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
cur = conn.execute(
"""INSERT INTO resumes (name, source, job_id, text, struct_json, word_count)
VALUES (?, ?, ?, ?, ?, ?)""",
(name, source, job_id, text, struct_json, word_count),
)
conn.commit()
row = conn.execute("SELECT * FROM resumes WHERE id=?", (cur.lastrowid,)).fetchone()
return _resume_as_dict(row)
finally:
conn.close()
def list_resumes(db_path: Path = DEFAULT_DB) -> list[dict]:
"""Return all resumes ordered by default-first then newest-first."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"SELECT * FROM resumes ORDER BY is_default DESC, created_at DESC"
).fetchall()
return [_resume_as_dict(r) for r in rows]
finally:
conn.close()
def get_resume(db_path: Path = DEFAULT_DB, resume_id: int = 0) -> dict | None:
"""Return a single resume by id, or None if not found."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
row = conn.execute("SELECT * FROM resumes WHERE id=?", (resume_id,)).fetchone()
return _resume_as_dict(row) if row else None
finally:
conn.close()
def update_resume(
db_path: Path = DEFAULT_DB,
resume_id: int = 0,
name: str | None = None,
text: str | None = None,
) -> dict | None:
"""Update name and/or text of a resume. Returns updated row or None."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
if name is not None:
conn.execute(
"UPDATE resumes SET name=?, updated_at=datetime('now') WHERE id=?",
(name, resume_id),
)
if text is not None:
word_count = len(text.split())
conn.execute(
"UPDATE resumes SET text=?, word_count=?, updated_at=datetime('now') WHERE id=?",
(text, word_count, resume_id),
)
conn.commit()
row = conn.execute("SELECT * FROM resumes WHERE id=?", (resume_id,)).fetchone()
return _resume_as_dict(row) if row else None
finally:
conn.close()
def delete_resume(db_path: Path = DEFAULT_DB, resume_id: int = 0) -> None:
"""Delete a resume by id."""
conn = sqlite3.connect(db_path)
try:
conn.execute("DELETE FROM resumes WHERE id=?", (resume_id,))
conn.commit()
finally:
conn.close()
def set_default_resume(db_path: Path = DEFAULT_DB, resume_id: int = 0) -> None:
"""Set one resume as default, clearing the flag on all others."""
conn = sqlite3.connect(db_path)
try:
conn.execute("UPDATE resumes SET is_default=0")
conn.execute("UPDATE resumes SET is_default=1 WHERE id=?", (resume_id,))
conn.commit()
finally:
conn.close()
def get_job_resume(db_path: Path = DEFAULT_DB, job_id: int = 0) -> dict | None:
"""Return the resume for a job: job-specific first, then default, then None."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
row = conn.execute(
"""SELECT r.* FROM resumes r
JOIN jobs j ON j.resume_id = r.id
WHERE j.id=?""",
(job_id,),
).fetchone()
if row:
return _resume_as_dict(row)
row = conn.execute(
"SELECT * FROM resumes WHERE is_default=1 LIMIT 1"
).fetchone()
return _resume_as_dict(row) if row else None
finally:
conn.close()
def set_job_resume(db_path: Path = DEFAULT_DB, job_id: int = 0, resume_id: int = 0) -> None:
"""Attach a specific resume to a job (overrides default for that job)."""
conn = sqlite3.connect(db_path)
try:
conn.execute("UPDATE jobs SET resume_id=? WHERE id=?", (resume_id, job_id))
conn.commit()
finally:
conn.close()

89
tests/test_db_resumes.py Normal file
View file

@ -0,0 +1,89 @@
"""Tests for resume library db helpers."""
import sqlite3
import tempfile
from pathlib import Path
import pytest
from scripts.db_migrate import migrate_db
@pytest.fixture
def db(tmp_path):
path = tmp_path / "test.db"
migrate_db(path)
return path
def test_create_and_get_resume(db):
from scripts.db import create_resume, get_resume
r = create_resume(db, name="Q1 2026", text="Software engineer with 5 years experience.")
assert r["id"] > 0
assert r["name"] == "Q1 2026"
assert r["word_count"] == 6
assert r["source"] == "manual"
assert r["is_default"] == 0
fetched = get_resume(db, r["id"])
assert fetched["name"] == "Q1 2026"
def test_list_resumes(db):
from scripts.db import create_resume, list_resumes
create_resume(db, name="A", text="alpha beta")
create_resume(db, name="B", text="gamma delta")
results = list_resumes(db)
assert len(results) == 2
def test_update_resume(db):
from scripts.db import create_resume, update_resume
r = create_resume(db, name="Old name", text="old text here")
updated = update_resume(db, r["id"], name="New name", text="new text content here updated")
assert updated["name"] == "New name"
assert updated["word_count"] == 5
def test_delete_resume(db):
from scripts.db import create_resume, delete_resume, get_resume
r = create_resume(db, name="Temp", text="temp text")
delete_resume(db, r["id"])
assert get_resume(db, r["id"]) is None
def test_set_default_resume(db):
from scripts.db import create_resume, set_default_resume, list_resumes
a = create_resume(db, name="A", text="text a")
b = create_resume(db, name="B", text="text b")
set_default_resume(db, a["id"])
set_default_resume(db, b["id"])
resumes = {r["id"]: r for r in list_resumes(db)}
assert resumes[a["id"]]["is_default"] == 0
assert resumes[b["id"]]["is_default"] == 1
def test_get_job_resume_default_fallback(db):
from scripts.db import create_resume, set_default_resume, get_job_resume
# Insert a minimal job row
conn = sqlite3.connect(db)
conn.execute("INSERT INTO jobs (id, title, company, source) VALUES (1, 'Eng', 'Co', 'test')")
conn.commit()
conn.close()
r = create_resume(db, name="Default", text="default resume text")
set_default_resume(db, r["id"])
result = get_job_resume(db, 1)
assert result["id"] == r["id"]
def test_get_job_resume_job_specific_override(db):
from scripts.db import create_resume, set_default_resume, get_job_resume, set_job_resume
conn = sqlite3.connect(db)
conn.execute("INSERT INTO jobs (id, title, company, source) VALUES (1, 'Eng', 'Co', 'test')")
conn.commit()
conn.close()
default_r = create_resume(db, name="Default", text="default resume text")
set_default_resume(db, default_r["id"])
specific_r = create_resume(db, name="Specific", text="job specific resume text")
set_job_resume(db, job_id=1, resume_id=specific_r["id"])
result = get_job_resume(db, 1)
assert result["id"] == specific_r["id"]

129
tests/test_resumes_api.py Normal file
View file

@ -0,0 +1,129 @@
"""Tests for /api/resumes/* endpoints."""
import json
import io
import sqlite3
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from scripts.db_migrate import migrate_db
@pytest.fixture
def client(tmp_path, monkeypatch):
db_path = tmp_path / "test.db"
migrate_db(db_path)
monkeypatch.setenv("STAGING_DB", str(db_path))
import dev_api
monkeypatch.setattr(dev_api, "_request_db",
type("CV", (), {"get": lambda self: str(db_path), "set": lambda *a: None})())
return TestClient(dev_api.app), db_path
def test_create_and_list(client):
c, db = client
resp = c.post("/api/resumes", json={"name": "Base", "text": "Resume text here"})
assert resp.status_code == 200
r = resp.json()
assert r["name"] == "Base"
assert r["word_count"] == 3
resp2 = c.get("/api/resumes")
assert len(resp2.json()["resumes"]) == 1
def test_get_single(client):
c, db = client
created = c.post("/api/resumes", json={"name": "Test", "text": "text"}).json()
fetched = c.get(f"/api/resumes/{created['id']}").json()
assert fetched["name"] == "Test"
def test_patch_resume(client):
c, db = client
created = c.post("/api/resumes", json={"name": "Old", "text": "old text"}).json()
updated = c.patch(f"/api/resumes/{created['id']}", json={"name": "New"}).json()
assert updated["name"] == "New"
assert updated["text"] == "old text"
def test_delete_resume(client):
c, db = client
a = c.post("/api/resumes", json={"name": "A", "text": "text a"}).json()
b = c.post("/api/resumes", json={"name": "B", "text": "text b"}).json()
resp = c.delete(f"/api/resumes/{a['id']}")
assert resp.status_code == 200
assert len(c.get("/api/resumes").json()["resumes"]) == 1
def test_delete_only_resume_rejected(client):
c, db = client
r = c.post("/api/resumes", json={"name": "Only", "text": "text"}).json()
resp = c.delete(f"/api/resumes/{r['id']}")
assert resp.status_code == 409
def test_set_default(client):
c, db = client
a = c.post("/api/resumes", json={"name": "A", "text": "text a"}).json()
b = c.post("/api/resumes", json={"name": "B", "text": "text b"}).json()
c.post(f"/api/resumes/{a['id']}/set-default")
c.post(f"/api/resumes/{b['id']}/set-default")
resumes = {r["id"]: r for r in c.get("/api/resumes").json()["resumes"]}
assert resumes[a["id"]]["is_default"] == 0
assert resumes[b["id"]]["is_default"] == 1
def test_import_txt(client):
c, db = client
f = io.BytesIO(b"Software engineer with ten years experience building distributed systems.")
resp = c.post("/api/resumes/import", files={"file": ("resume.txt", f, "text/plain")},
data={"name": "Imported"})
assert resp.status_code == 200
r = resp.json()
assert r["source"] == "import"
assert r["word_count"] > 0
def test_import_yaml(client):
c, db = client
yaml_content = b"""
career_summary: Experienced engineer.
experience:
- title: Staff Engineer
company: Acme
start_date: 01/2020
end_date: Present
bullets:
- Led platform redesign serving 2M users
skills:
- Python
- FastAPI
"""
f = io.BytesIO(yaml_content)
resp = c.post("/api/resumes/import", files={"file": ("resume.yaml", f, "application/x-yaml")})
assert resp.status_code == 200
r = resp.json()
assert r["source"] == "import"
assert r["struct_json"] is not None
def test_per_job_resume_endpoints(client):
c, db = client
conn = sqlite3.connect(db)
conn.execute("INSERT INTO jobs (id, title, company, source) VALUES (1, 'Eng', 'Co', 'test')")
conn.commit()
conn.close()
r = c.post("/api/resumes", json={"name": "Default", "text": "default text"}).json()
c.post(f"/api/resumes/{r['id']}/set-default")
result = c.get("/api/jobs/1/resume").json()
assert result["id"] == r["id"]
specific = c.post("/api/resumes", json={"name": "Specific", "text": "specific text"}).json()
c.patch("/api/jobs/1/resume", json={"resume_id": specific["id"]})
result2 = c.get("/api/jobs/1/resume").json()
assert result2["id"] == specific["id"]

View file

@ -50,6 +50,9 @@
</select>
</div>
<!-- Background task queue (desktop: inline list; mobile: fixed pill above tab bar) -->
<TaskIndicator />
<!-- Settings at bottom -->
<div class="sidebar__footer">
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
@ -92,10 +95,12 @@ import {
MagnifyingGlassIcon,
NewspaperIcon,
Cog6ToothIcon,
DocumentTextIcon,
} from '@heroicons/vue/24/outline'
import { useDigestStore } from '../stores/digest'
import { useTheme, THEME_OPTIONS, type Theme } from '../composables/useTheme'
import TaskIndicator from './TaskIndicator.vue'
const digestStore = useDigestStore()
const { currentTheme, setTheme, restoreTheme } = useTheme()
@ -148,6 +153,7 @@ const navLinks = computed(() => [
{ to: '/', icon: HomeIcon, label: 'Home' },
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
{ to: '/resumes', icon: DocumentTextIcon, label: 'Resumes' },
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
{ to: '/digest', icon: NewspaperIcon, label: 'Digest',
badge: digestStore.entries.length || undefined },

View file

@ -191,6 +191,9 @@
Regenerate
</button>
<!-- Resume Library Card -->
<ResumeLibraryCard :job-id="props.jobId" class="apply__resume-card" />
<!-- ATS Resume Optimizer -->
<ResumeOptimizerPanel :job-id="props.jobId" />
@ -286,6 +289,7 @@ import { useApiFetch } from '../composables/useApi'
import { useAppConfigStore } from '../stores/appConfig'
import type { Job } from '../stores/review'
import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue'
import ResumeLibraryCard from './ResumeLibraryCard.vue'
const config = useAppConfigStore()

View file

@ -0,0 +1,198 @@
<template>
<div class="rlc">
<div class="rlc__header">
<h3 class="rlc__title"><span aria-hidden="true">📄</span> Resume</h3>
<div class="rlc__actions">
<button class="btn-secondary rlc__switch" @click="showPicker = !showPicker">Switch</button>
<RouterLink to="/resumes" class="btn-secondary rlc__manage">Manage</RouterLink>
</div>
</div>
<div v-if="loading" class="rlc__loading">Loading</div>
<div v-else-if="resume" class="rlc__resume">
<span class="rlc__name">{{ resume.name }}</span>
<span class="rlc__meta">{{ resume.word_count }} words</span>
<span v-if="resume.job_id === jobId" class="rlc__optimized-badge"> Optimized for this job</span>
</div>
<div v-else class="rlc__empty">
No resume attached.
<RouterLink to="/resumes" class="rlc__import-link">Import one</RouterLink>
or optimize below.
</div>
<!-- Picker dropdown -->
<div v-if="showPicker && allResumes.length" class="rlc__picker">
<ul class="rlc__picker-list">
<li
v-for="r in allResumes"
:key="r.id"
class="rlc__picker-item"
:class="{ 'rlc__picker-item--active': resume?.id === r.id }"
@click="switchResume(r.id)"
>
<span>{{ r.name }}</span>
<span class="rlc__picker-meta">{{ r.word_count }}w</span>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useApiFetch } from '../composables/useApi'
interface Resume {
id: number
name: string
word_count: number
job_id: number | null
is_default: number
}
const props = defineProps<{ jobId: number }>()
const resume = ref<Resume | null>(null)
const allResumes = ref<Resume[]>([])
const loading = ref(true)
const showPicker = ref(false)
async function load() {
loading.value = true
const [jobRes, listRes] = await Promise.all([
useApiFetch<Resume>(`/api/jobs/${props.jobId}/resume`),
useApiFetch<{ resumes: Resume[] }>('/api/resumes'),
])
resume.value = jobRes.data ?? null
allResumes.value = listRes.data?.resumes ?? []
loading.value = false
}
async function switchResume(resumeId: number) {
showPicker.value = false
const { data } = await useApiFetch<Resume>(
`/api/jobs/${props.jobId}/resume`,
{
method: 'PATCH',
body: JSON.stringify({ resume_id: resumeId }),
headers: { 'Content-Type': 'application/json' },
}
)
if (data) resume.value = data
}
onMounted(load)
</script>
<style scoped>
.rlc {
background: var(--color-surface-alt, #f8fafc);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 0.5rem);
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
display: flex;
flex-direction: column;
gap: var(--space-2, 0.5rem);
position: relative;
}
.rlc__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.rlc__title {
font-size: var(--font-sm, 0.875rem);
font-weight: 600;
margin: 0;
display: flex;
align-items: center;
gap: var(--space-2, 0.5rem);
}
.rlc__actions {
display: flex;
gap: var(--space-2, 0.5rem);
}
.rlc__resume {
display: flex;
align-items: center;
gap: var(--space-2, 0.5rem);
flex-wrap: wrap;
}
.rlc__name {
font-weight: 500;
font-size: var(--font-sm, 0.875rem);
}
.rlc__meta {
font-size: var(--font-xs, 0.75rem);
color: var(--color-text-muted, #64748b);
}
.rlc__optimized-badge {
font-size: var(--font-xs, 0.75rem);
color: var(--color-accent, #6366f1);
font-weight: 500;
}
.rlc__empty {
font-size: var(--font-sm, 0.875rem);
color: var(--color-text-muted, #64748b);
}
.rlc__import-link {
color: var(--color-accent, #6366f1);
text-decoration: underline;
}
.rlc__loading {
font-size: var(--font-sm, 0.875rem);
color: var(--color-text-muted, #64748b);
}
.rlc__picker {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 0.5rem);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin-top: 4px;
}
.rlc__picker-list {
list-style: none;
margin: 0;
padding: var(--space-1, 0.25rem);
}
.rlc__picker-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
cursor: pointer;
border-radius: var(--radius-sm, 0.25rem);
font-size: var(--font-sm, 0.875rem);
}
.rlc__picker-item:hover,
.rlc__picker-item--active {
background: var(--color-surface-alt, #f8fafc);
}
.rlc__picker-meta {
font-size: var(--font-xs, 0.75rem);
color: var(--color-text-muted, #64748b);
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,551 @@
<template>
<Teleport to="body">
<div
class="rrm-backdrop"
role="dialog"
aria-modal="true"
:aria-labelledby="`rrm-title-${jobId}`"
@keydown.esc="handleEscape"
@click.self="handleEscape"
>
<div class="rrm-card" ref="cardRef" tabindex="-1">
<!-- Header -->
<div class="rrm__header">
<h2 :id="`rrm-title-${jobId}`" class="rrm__title">Resume Review</h2>
<button class="rrm__close" aria-label="Close review" @click="handleEscape"></button>
</div>
<!-- Tab bar -->
<div class="rrm__tabs" role="tablist" aria-label="Resume sections">
<button
v-for="(page, idx) in pages"
:key="page.id"
role="tab"
:id="`rrm-tab-${page.id}`"
:aria-selected="idx === currentIdx"
:aria-controls="`rrm-panel-${page.id}`"
class="rrm__tab"
:class="{ 'rrm__tab--active': idx === currentIdx }"
@click="goTo(idx)"
>
<span
v-if="page.type !== 'confirm'"
class="tab__dot"
:class="`tab__dot--${tabStatus(page)}`"
:aria-label="`${page.label}: ${tabStatus(page)}`"
/>
<span class="tab__label">{{ page.label }}</span>
</button>
</div>
<!-- Page content -->
<div
:id="`rrm-panel-${currentPage.id}`"
class="rrm__content"
role="tabpanel"
:aria-labelledby="`rrm-tab-${currentPage.id}`"
>
<!-- Skills page -->
<template v-if="currentPage.type === 'skills'">
<SkillsPage
:section="skillsSection!"
:approved-skills="approvedSkills"
:skill-framings="skillFramings"
@toggle-skill="toggleSkill"
@set-framing-mode="setFramingMode"
@set-framing-context="setFramingContext"
/>
</template>
<!-- Summary page -->
<template v-else-if="currentPage.type === 'summary'">
<SummaryPage
:section="summarySection!"
:accepted="summaryAccepted"
@update:accepted="summaryAccepted = $event"
/>
</template>
<!-- Experience page -->
<template v-else-if="currentPage.type === 'experience'">
<ExperiencePage
:entry="currentEntry!"
:accepted="expAccepted[currentPage.entryKey!] ?? true"
@update:accepted="expAccepted[currentPage.entryKey!] = $event"
/>
</template>
<!-- Confirm page -->
<template v-else-if="currentPage.type === 'confirm'">
<ConfirmPage
:pages="pages.filter(p => p.type !== 'confirm')"
:tab-status="tabStatus"
:submitting="submitting"
:error="submitError"
@preview="emitSubmit"
@rewrite="emit('rewriteAgain')"
/>
</template>
</div>
<!-- Footer navigation -->
<div class="rrm__footer">
<button
class="rrm__back btn-secondary"
:disabled="currentIdx === 0"
@click="goTo(currentIdx - 1)"
>
Back
</button>
<span class="rrm__page-counter">
{{ currentIdx + 1 }} / {{ pages.length }}
</span>
<button
v-if="currentPage.type !== 'confirm'"
class="rrm__next btn-generate"
@click="goTo(currentIdx + 1)"
>
Next
</button>
<button
v-else
class="rrm__preview btn-generate"
:disabled="submitting"
@click="emitSubmit"
>
<span aria-hidden="true">👁</span>
{{ submitting ? 'Generating…' : 'Preview Full Resume' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import SkillsPage from './resume-review/SkillsPage.vue'
import SummaryPage from './resume-review/SummaryPage.vue'
import ExperiencePage from './resume-review/ExperiencePage.vue'
import ConfirmPage from './resume-review/ConfirmPage.vue'
// Types
type TabStatus = 'unvisited' | 'in-progress' | 'accepted' | 'partial' | 'skipped'
type PageType = 'skills' | 'summary' | 'experience' | 'confirm'
type GapFramingMode = 'skip' | 'adjacent' | 'learning'
interface GapFraming { mode: GapFramingMode; context: string }
export interface SkillsDiff {
section: 'skills'
type: 'skills_diff'
added: string[]
removed: string[]
kept: string[]
}
export interface TextDiff {
section: 'summary'
type: 'text_diff'
original: string
proposed: string
}
export interface BulletsDiff {
section: 'experience'
type: 'bullets_diff'
entries: Array<{
title: string
company: string
original_bullets: string[]
proposed_bullets: string[]
}>
}
export type SectionDiff = SkillsDiff | TextDiff | BulletsDiff
export interface ReviewDraft {
sections: SectionDiff[]
rewritten_struct: Record<string, unknown>
gap_report?: unknown[]
}
interface Page {
id: string
type: PageType
label: string
entryKey?: string
}
// Props / emits
const props = defineProps<{
jobId: number
draft: ReviewDraft
}>()
const emit = defineEmits<{
close: []
submit: [decisions: Record<string, unknown>]
rewriteAgain: []
}>()
// Section accessors
const skillsSection = computed(() =>
props.draft.sections.find(s => s.section === 'skills') as SkillsDiff | undefined
)
const summarySection = computed(() =>
props.draft.sections.find(s => s.section === 'summary') as TextDiff | undefined
)
const expSection = computed(() =>
props.draft.sections.find(s => s.section === 'experience') as BulletsDiff | undefined
)
// Page list
const pages = computed<Page[]>(() => {
const result: Page[] = []
const skills = skillsSection.value
const summary = summarySection.value
const exp = expSection.value
if (skills && skills.added.length > 0) {
result.push({ id: 'skills', type: 'skills', label: 'Skills' })
}
if (summary) {
result.push({ id: 'summary', type: 'summary', label: 'Summary' })
}
for (const e of (exp?.entries ?? [])) {
const key = `${e.title}|${e.company}`
result.push({ id: key, type: 'experience', label: e.title, entryKey: key })
}
result.push({ id: 'confirm', type: 'confirm', label: 'Confirm' })
return result
})
// Navigation state
const currentIdx = ref(0)
const visitedPages = ref<Set<string>>(new Set())
// Pages where the user has explicitly made a decision (toggled/changed something)
const interactedPages = ref<Set<string>>(new Set())
const currentPage = computed(() => pages.value[currentIdx.value])
const currentEntry = computed(() => {
const key = currentPage.value.entryKey
if (!key) return undefined
return expSection.value?.entries.find(e => `${e.title}|${e.company}` === key)
})
function goTo(idx: number) {
if (idx < 0 || idx >= pages.value.length) return
// Mark current page as visited before leaving
visitedPages.value = new Set([...visitedPages.value, currentPage.value.id])
currentIdx.value = idx
// Mark destination as visited on arrival
visitedPages.value = new Set([...visitedPages.value, pages.value[idx].id])
}
// Decision state
const approvedSkills = ref<Set<string>>(new Set(skillsSection.value?.added ?? []))
const skillFramings = ref<Map<string, GapFraming>>(new Map())
const summaryAccepted = ref(true)
const expAccepted = ref<Record<string, boolean>>(
Object.fromEntries(
(expSection.value?.entries ?? []).map(e => [`${e.title}|${e.company}`, true])
)
)
function toggleSkill(skill: string) {
interactedPages.value = new Set([...interactedPages.value, 'skills'])
const next = new Set(approvedSkills.value)
if (next.has(skill)) {
next.delete(skill)
if (!skillFramings.value.has(skill)) {
skillFramings.value = new Map([...skillFramings.value, [skill, { mode: 'skip', context: '' }]])
}
} else {
next.add(skill)
const next2 = new Map(skillFramings.value)
next2.delete(skill)
skillFramings.value = next2
}
approvedSkills.value = next
}
function setFramingMode(skill: string, mode: GapFramingMode) {
const existing = skillFramings.value.get(skill) ?? { mode: 'skip' as GapFramingMode, context: '' }
skillFramings.value = new Map([...skillFramings.value, [skill, { ...existing, mode }]])
}
function setFramingContext(skill: string, context: string) {
const existing = skillFramings.value.get(skill) ?? { mode: 'skip' as GapFramingMode, context: '' }
skillFramings.value = new Map([...skillFramings.value, [skill, { ...existing, context }]])
}
// Tab status
function tabStatus(page: Page): TabStatus {
if (!visitedPages.value.has(page.id)) return 'unvisited'
// Only report a resolved status if the user has explicitly interacted
if (!interactedPages.value.has(page.id)) return 'in-progress'
if (page.type === 'skills') {
const total = skillsSection.value?.added.length ?? 0
const approved = approvedSkills.value.size
if (approved === total) return 'accepted'
if (approved === 0) return 'skipped'
return 'partial'
}
if (page.type === 'summary') {
return summaryAccepted.value ? 'accepted' : 'skipped'
}
if (page.type === 'experience' && page.entryKey) {
return (expAccepted.value[page.entryKey] ?? true) ? 'accepted' : 'skipped'
}
return 'in-progress'
}
// Submit
const submitting = ref(false)
const submitError = ref('')
function emitSubmit() {
const decisions: Record<string, unknown> = {}
if (skillsSection.value) {
decisions.skills = { approved_additions: [...approvedSkills.value] }
}
if (summarySection.value) {
decisions.summary = { accepted: summaryAccepted.value }
}
if (expSection.value) {
decisions.experience = {
accepted_entries: expSection.value.entries.map(e => ({
title: e.title,
company: e.company,
accepted: expAccepted.value[`${e.title}|${e.company}`] ?? true,
})),
}
}
const gap_framings = [...skillFramings.value.entries()]
.filter(([, f]) => f.mode !== 'skip')
.map(([skill, f]) => ({ skill, mode: f.mode, context: f.context }))
if (gap_framings.length) decisions.gap_framings = gap_framings
emit('submit', decisions)
}
// Escape / focus
function handleEscape() {
emit('close')
}
const cardRef = ref<HTMLElement | null>(null)
onMounted(() => {
cardRef.value?.focus()
})
function trapFocus(e: KeyboardEvent) {
if (e.key !== 'Tab' || !cardRef.value) return
const focusable = cardRef.value.querySelectorAll<HTMLElement>(
'button:not([disabled]), input, textarea, select, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
onMounted(() => document.addEventListener('keydown', trapFocus))
onUnmounted(() => document.removeEventListener('keydown', trapFocus))
</script>
<style scoped>
.rrm-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--space-4, 1rem);
}
.rrm-card {
background: var(--color-surface-raised, #f5f7fc);
border-radius: var(--radius-lg, 1rem);
box-shadow: var(--shadow-lg, 0 10px 30px rgba(26, 35, 56, 0.12));
width: 100%;
max-width: 860px;
max-height: 90vh;
display: flex;
flex-direction: column;
outline: none;
}
/* Header */
.rrm__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4, 1rem) var(--space-6, 1.5rem);
border-bottom: 1px solid var(--color-border, #a8b8d0);
flex-shrink: 0;
}
.rrm__title {
font-size: var(--font-lg, 1.125rem);
font-weight: 600;
margin: 0;
color: var(--color-text, #1a2338);
font-family: var(--font-display, Georgia, serif);
}
.rrm__close {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
color: var(--color-text-muted, #4a5c7a);
padding: var(--space-1, 0.25rem) var(--space-2, 0.5rem);
border-radius: var(--radius-sm, 0.25rem);
line-height: 1;
transition: color var(--transition, 200ms ease);
}
.rrm__close:hover { color: var(--color-text, #1a2338); }
/* Tab bar */
.rrm__tabs {
display: flex;
flex-shrink: 0;
overflow-x: auto;
scrollbar-width: none;
border-bottom: 1px solid var(--color-border, #a8b8d0);
padding: 0 var(--space-4, 1rem);
background: var(--color-surface, #eaeff8);
}
.rrm__tabs::-webkit-scrollbar { display: none; }
.rrm__tab {
display: flex;
align-items: center;
gap: var(--space-2, 0.5rem);
padding: var(--space-3, 0.75rem) var(--space-3, 0.75rem);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
white-space: nowrap;
color: var(--color-text-muted, #4a5c7a);
transition: color var(--transition, 200ms ease), border-color var(--transition, 200ms ease);
}
.rrm__tab--active {
color: var(--color-text, #1a2338);
border-bottom-color: var(--color-accent, #c4732a);
}
.rrm__tab:hover { color: var(--color-text, #1a2338); }
.tab__label { font-size: 0.875rem; }
.tab__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--tab-color, #94a3b8);
display: inline-block;
}
.tab__dot--unvisited { --tab-color: var(--color-text-muted, #4a5c7a); opacity: 0.4; }
.tab__dot--in-progress { --tab-color: var(--color-accent, #c4732a); }
.tab__dot--accepted { --tab-color: var(--color-success, #3a7a32); }
.tab__dot--partial { --tab-color: var(--color-warning, #d4891a); }
.tab__dot--skipped { --tab-color: var(--color-border, #a8b8d0); }
/* Content */
.rrm__content {
flex: 1;
overflow-y: auto;
padding: var(--space-6, 1.5rem);
}
/* Footer */
.rrm__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4, 1rem) var(--space-6, 1.5rem);
border-top: 1px solid var(--color-border, #a8b8d0);
flex-shrink: 0;
background: var(--color-surface, #eaeff8);
border-radius: 0 0 var(--radius-lg, 1rem) var(--radius-lg, 1rem);
}
.rrm__page-counter {
font-size: 0.875rem;
color: var(--color-text-muted, #4a5c7a);
}
/* Shared button styles (scoped — footer only) */
.btn-generate {
display: inline-flex;
align-items: center;
gap: var(--space-2, 0.5rem);
padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
background: var(--color-accent, #c4732a);
color: #fff;
border: none;
border-radius: var(--radius-md, 0.5rem);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition, 200ms ease);
}
.btn-generate:hover:not(:disabled) { background: var(--color-accent-hover, #a85c1f); }
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary {
display: inline-flex;
align-items: center;
gap: var(--space-2, 0.5rem);
padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
background: var(--color-surface-alt, #dde4f0);
color: var(--color-text, #1a2338);
border: 1px solid var(--color-border, #a8b8d0);
border-radius: var(--radius-md, 0.5rem);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition, 200ms ease);
}
.btn-secondary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary:hover:not(:disabled) { background: var(--color-surface, #eaeff8); }
/* Mobile */
@media (max-width: 600px) {
.rrm-card { max-height: 100vh; border-radius: 0; }
.rrm-backdrop { padding: 0; align-items: flex-end; }
.rrm__content { padding: var(--space-4, 1rem); }
}
</style>

View file

@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ResumeReviewModal from '../ResumeReviewModal.vue'
const minimalDraft = {
sections: [
{ section: 'skills', type: 'skills_diff', added: ['Python', 'FastAPI'], removed: [], kept: ['Git'] },
{ section: 'summary', type: 'text_diff', original: 'Old summary.', proposed: 'New summary.' },
{
section: 'experience', type: 'bullets_diff',
entries: [
{ title: 'Staff Eng', company: 'Acme', original_bullets: ['Did A'], proposed_bullets: ['Led A'] },
{ title: 'SWE', company: 'Beta', original_bullets: ['Did B'], proposed_bullets: ['Led B'] },
],
},
],
rewritten_struct: {},
}
const factory = (draft = minimalDraft) =>
mount(ResumeReviewModal, {
props: { jobId: 1, draft },
global: { stubs: { Teleport: true } },
})
describe('page generation', () => {
it('generates skills + summary + 2 experience + confirm = 5 pages', () => {
const w = factory()
expect(w.findAll('[role="tab"]').length).toBe(5)
})
it('confirm tab has no status dot', () => {
const w = factory()
const tabs = w.findAll('[role="tab"]')
const confirmTab = tabs[tabs.length - 1]
expect(confirmTab.text()).toContain('Confirm')
expect(confirmTab.find('.tab__dot').exists()).toBe(false)
})
})
describe('tab status', () => {
it('all non-confirm tabs start as unvisited', () => {
const w = factory()
const dots = w.findAll('.tab__dot')
dots.forEach(d => expect(d.classes()).toContain('tab__dot--unvisited'))
})
it('visiting a page marks it in-progress', async () => {
const w = factory()
await w.find('[role="tab"]').trigger('click')
const firstDot = w.findAll('.tab__dot')[0]
expect(firstDot.classes()).toContain('tab__dot--in-progress')
})
})
describe('navigation', () => {
it('next button advances to page 2', async () => {
const w = factory()
expect(w.find('.rrm__page-counter').text()).toContain('1')
await w.find('.rrm__next').trigger('click')
expect(w.find('.rrm__page-counter').text()).toContain('2')
})
it('back button on page 1 is disabled', () => {
const w = factory()
expect(w.find('.rrm__back').attributes('disabled')).toBeDefined()
})
it('emits close when X button clicked', async () => {
const w = factory()
await w.find('.rrm__close').trigger('click')
expect(w.emitted('close')).toBeTruthy()
})
})
describe('decisions emit', () => {
it('emits submit with correct shape when Preview clicked on confirm page', async () => {
const w = factory()
// Navigate to confirm page (page 5 = index 4)
for (let i = 0; i < 4; i++) {
await w.find('.rrm__next').trigger('click')
}
await w.find('.rrm__preview').trigger('click')
const emitted = w.emitted('submit')
expect(emitted).toBeTruthy()
const decisions = emitted![0][0] as Record<string, unknown>
expect(decisions).toHaveProperty('skills')
expect(decisions).toHaveProperty('summary')
expect(decisions).toHaveProperty('experience')
})
})

View file

@ -0,0 +1,77 @@
<template>
<div class="rp-confirm">
<h3 class="rp__heading">Ready to preview?</h3>
<p class="rp__hint">Here's a summary of your decisions. Click Preview to assemble the resume.</p>
<ul class="rp-confirm__list">
<li v-for="page in pages" :key="page.id" class="rp-confirm__item">
<span class="tab__dot" :class="`tab__dot--${tabStatus(page)}`" aria-hidden="true" />
<span>{{ page.label }}</span>
<span class="rp-confirm__status">{{ tabStatus(page) }}</span>
</li>
</ul>
<p v-if="error" class="rp__error" role="alert">{{ error }}</p>
<div class="rp-confirm__actions">
<button class="btn-generate" :disabled="submitting" @click="emit('preview')">
<span aria-hidden="true">👁</span>
{{ submitting ? 'Generating…' : 'Preview Full Resume' }}
</button>
<button class="btn-secondary" @click="emit('rewrite')">
<span aria-hidden="true">🔄</span> Rewrite again
</button>
</div>
</div>
</template>
<script setup lang="ts">
interface PageRef {
id: string
type: string
label: string
entryKey?: string
}
defineProps<{
pages: PageRef[]
tabStatus: (page: PageRef) => string
submitting: boolean
error: string
}>()
const emit = defineEmits<{
preview: []
rewrite: []
}>()
</script>
<style scoped>
.rp-confirm { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
.rp__hint { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; }
.rp-confirm__list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
.rp-confirm__item { display: flex; align-items: center; gap: var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); font-size: var(--font-sm, 0.875rem); }
.rp-confirm__status { margin-left: auto; font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #4a5c7a); text-transform: capitalize; }
.rp__error { color: var(--color-error, #c0392b); font-size: var(--font-sm, 0.875rem); margin: 0; }
.rp-confirm__actions { display: flex; gap: var(--space-3, 0.75rem); flex-wrap: wrap; }
.tab__dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--tab-color, #94a3b8); }
.tab__dot--unvisited { --tab-color: var(--color-text-muted, #94a3b8); }
.tab__dot--in-progress { --tab-color: var(--color-accent, #c4732a); }
.tab__dot--accepted { --tab-color: var(--color-success, #3a7a32); }
.tab__dot--partial { --tab-color: var(--color-warning, #d4891a); }
.tab__dot--skipped { --tab-color: var(--color-border, #a8b8d0); }
.btn-generate {
display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem);
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
background: var(--color-accent, #c4732a); color: #fff;
border: none; border-radius: var(--radius-md, 0.5rem);
font-size: var(--font-sm, 0.875rem); font-weight: 600; cursor: pointer;
}
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary {
display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem);
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
background: var(--color-surface-alt, #dde4f0); color: var(--color-text, #1a2338);
border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-md, 0.5rem);
font-size: var(--font-sm, 0.875rem); font-weight: 600; cursor: pointer;
}
</style>

View file

@ -0,0 +1,56 @@
<template>
<div class="rp-exp">
<h3 class="rp__heading">{{ entry.title }}</h3>
<p class="rp__company">{{ entry.company }}</p>
<div class="rp__diff-pair">
<div class="rp__diff-col">
<span class="rp__diff-label">Original</span>
<ul class="rp__bullet-list">
<li v-for="b in entry.original_bullets" :key="b">{{ b }}</li>
</ul>
</div>
<div class="rp__diff-col">
<span class="rp__diff-label">Proposed</span>
<ul class="rp__bullet-list">
<li v-for="b in entry.proposed_bullets" :key="b">{{ b }}</li>
</ul>
</div>
</div>
<label class="rp__accept-toggle">
<input
type="checkbox"
:checked="accepted"
@change="emit('update:accepted', ($event.target as HTMLInputElement).checked)"
/>
Accept proposed bullets
</label>
</div>
</template>
<script setup lang="ts">
defineProps<{
entry: {
title: string
company: string
original_bullets: string[]
proposed_bullets: string[]
}
accepted: boolean
}>()
const emit = defineEmits<{
'update:accepted': [v: boolean]
}>()
</script>
<style scoped>
.rp-exp { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
.rp__company { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; }
.rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); }
@media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } }
.rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
.rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); }
.rp__bullet-list { margin: 0; padding-left: var(--space-4, 1rem); font-size: var(--font-sm, 0.875rem); line-height: 1.6; background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-6, 1.5rem); }
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); }
</style>

View file

@ -0,0 +1,78 @@
<template>
<div class="rp-skills">
<h3 class="rp__heading">Skills</h3>
<p class="rp__hint">Check each skill you genuinely have. Uncheck skills you don't and choose how to frame the gap.</p>
<div class="rp__skill-list" role="group" aria-label="Proposed new skills">
<div v-for="skill in section.added" :key="skill" class="rp__skill-group">
<label class="rp__skill-chip" :class="{ 'rp__skill-chip--approved': approvedSkills.has(skill) }">
<input type="checkbox" :checked="approvedSkills.has(skill)" @change="emit('toggleSkill', skill)" />
{{ skill }}
</label>
<div v-if="!approvedSkills.has(skill)" class="rp__framing">
<span class="rp__framing-label">Frame this gap:</span>
<label><input type="radio" :name="`framing-${skill}`" value="skip"
:checked="(skillFramings.get(skill)?.mode ?? 'skip') === 'skip'"
@change="emit('setFramingMode', skill, 'skip')" /> Skip entirely</label>
<label><input type="radio" :name="`framing-${skill}`" value="adjacent"
:checked="skillFramings.get(skill)?.mode === 'adjacent'"
@change="emit('setFramingMode', skill, 'adjacent')" /> Adjacent experience</label>
<label><input type="radio" :name="`framing-${skill}`" value="learning"
:checked="skillFramings.get(skill)?.mode === 'learning'"
@change="emit('setFramingMode', skill, 'learning')" /> Actively learning</label>
<textarea v-if="skillFramings.get(skill)?.mode === 'adjacent'"
class="rp__framing-context" rows="2"
placeholder="Describe related background"
:value="skillFramings.get(skill)?.context ?? ''"
@input="emit('setFramingContext', skill, ($event.target as HTMLTextAreaElement).value)" />
<textarea v-else-if="skillFramings.get(skill)?.mode === 'learning'"
class="rp__framing-context" rows="2"
placeholder="Describe learning context"
:value="skillFramings.get(skill)?.context ?? ''"
@input="emit('setFramingContext', skill, ($event.target as HTMLTextAreaElement).value)" />
</div>
</div>
</div>
<p v-if="section.removed.length" class="rp__removed">Skills being removed: {{ section.removed.join(', ') }}</p>
</div>
</template>
<script setup lang="ts">
import type { SkillsDiff } from '../ResumeReviewModal.vue'
type GapFraming = { mode: 'skip' | 'adjacent' | 'learning'; context: string }
defineProps<{
section: SkillsDiff
approvedSkills: Set<string>
skillFramings: Map<string, GapFraming>
}>()
const emit = defineEmits<{
toggleSkill: [skill: string]
setFramingMode: [skill: string, mode: 'skip' | 'adjacent' | 'learning']
setFramingContext: [skill: string, context: string]
}>()
</script>
<style scoped>
.rp-skills { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
.rp__hint { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; }
.rp__skill-list { display: flex; flex-direction: column; gap: var(--space-3, 0.75rem); }
.rp__skill-group { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
.rp__skill-chip {
display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem);
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
border: 1px solid var(--color-border, #a8b8d0);
border-radius: var(--radius-md, 0.5rem);
cursor: pointer; font-size: var(--font-sm, 0.875rem);
background: var(--color-surface-raised, #f5f7fc);
transition: background var(--transition, 200ms ease);
}
.rp__skill-chip--approved { background: var(--color-primary-light, #e8f2e7); border-color: var(--color-primary, #2d5a27); }
.rp__framing { padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); }
.rp__framing-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; color: var(--color-text-muted, #4a5c7a); }
.rp__framing-context { border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-2, 0.5rem); font-size: var(--font-sm, 0.875rem); resize: vertical; }
.rp__removed { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); font-style: italic; }
</style>

View file

@ -0,0 +1,47 @@
<template>
<div class="rp-summary">
<h3 class="rp__heading">Summary</h3>
<div class="rp__diff-pair">
<div class="rp__diff-col">
<span class="rp__diff-label" aria-label="Original">Original</span>
<p class="rp__diff-text">{{ section.original || '(empty)' }}</p>
</div>
<div class="rp__diff-col">
<span class="rp__diff-label" aria-label="Proposed">Proposed</span>
<p class="rp__diff-text">{{ section.proposed }}</p>
</div>
</div>
<label class="rp__accept-toggle">
<input
type="checkbox"
:checked="accepted"
@change="emit('update:accepted', ($event.target as HTMLInputElement).checked)"
/>
Accept proposed summary
</label>
</div>
</template>
<script setup lang="ts">
import type { TextDiff } from '../ResumeReviewModal.vue'
defineProps<{
section: TextDiff
accepted: boolean
}>()
const emit = defineEmits<{
'update:accepted': [v: boolean]
}>()
</script>
<style scoped>
.rp-summary { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
.rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); }
@media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } }
.rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
.rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); }
.rp__diff-text { font-size: var(--font-sm, 0.875rem); line-height: 1.6; padding: var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); margin: 0; }
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); }
</style>

View file

@ -10,6 +10,7 @@ export const router = createRouter({
{ path: '/review', component: () => import('../views/JobReviewView.vue') },
{ path: '/apply', component: () => import('../views/ApplyView.vue') },
{ path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') },
{ path: '/resumes', component: () => import('../views/ResumesView.vue') },
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') },
{ path: '/digest', component: () => import('../views/DigestView.vue') },
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },

View file

@ -0,0 +1,306 @@
<template>
<div class="rv">
<div class="rv__header">
<h1 class="rv__title">Resume Library</h1>
<label class="btn-generate rv__import-btn">
<span aria-hidden="true">📥</span> Import
<input type="file" accept=".txt,.pdf,.docx,.odt,.yaml,.yml"
class="rv__file-input" @change="handleImport" />
</label>
</div>
<div v-if="loading" class="rv__loading">Loading</div>
<div v-else-if="resumes.length === 0" class="rv__empty">
<p>No resumes saved yet.</p>
<p>Import a resume file or save an optimized resume from the Apply workspace.</p>
</div>
<div v-else class="rv__layout">
<!-- Left: list -->
<ul class="rv__list" role="listbox" aria-label="Saved resumes">
<li
v-for="r in resumes"
:key="r.id"
role="option"
:aria-selected="selected?.id === r.id"
class="rv__list-item"
:class="{ 'rv__list-item--active': selected?.id === r.id }"
@click="select(r)"
>
<span class="rv__item-star" :aria-label="r.is_default ? 'Default resume' : ''">
{{ r.is_default ? '★' : '☆' }}
</span>
<div class="rv__item-info">
<span class="rv__item-name">{{ r.name }}</span>
<span class="rv__item-meta">{{ r.word_count }} words · {{ fmtDate(r.created_at) }}</span>
<span v-if="r.job_id" class="rv__item-source">Built for job #{{ r.job_id }}</span>
</div>
</li>
</ul>
<!-- Right: preview + actions -->
<div v-if="selected" class="rv__preview-pane">
<div class="rv__preview-header">
<div class="rv__preview-meta">
<h2 class="rv__preview-name">{{ editing ? editName : selected.name }}</h2>
<span class="rv__preview-words">{{ selected.word_count }} words</span>
<span v-if="selected.is_default" class="rv__default-badge">Default</span>
</div>
<div class="rv__preview-actions">
<button v-if="!selected.is_default" class="btn-secondary" @click="setDefault">
Set as Default
</button>
<button class="btn-secondary" @click="toggleEdit">
{{ editing ? 'Cancel' : 'Edit' }}
</button>
<div class="rv__download-menu">
<button class="btn-secondary" @click="showDownloadMenu = !showDownloadMenu">
Download
</button>
<ul v-if="showDownloadMenu" class="rv__download-dropdown">
<li><button @click="downloadTxt">Download .txt</button></li>
<li><button @click="downloadPdf">Download PDF</button></li>
<li><button @click="downloadYaml">Download YAML</button></li>
</ul>
</div>
<button class="rv__delete-btn" @click="confirmDelete"
:disabled="resumes.length === 1 || selected.is_default === 1">
Delete
</button>
</div>
</div>
<!-- Edit name inline -->
<input v-if="editing" v-model="editName" class="rv__edit-name-input" maxlength="80" placeholder="Resume name" />
<!-- Text editor / preview -->
<textarea
v-model="editText"
class="rv__textarea"
:readonly="!editing"
spellcheck="false"
aria-label="Resume text"
/>
<div v-if="editing" class="rv__edit-actions">
<button class="btn-generate" :disabled="saving" @click="saveEdit">
{{ saving ? 'Saving…' : 'Save' }}
</button>
<button class="btn-secondary" @click="toggleEdit">Discard</button>
</div>
<p v-if="actionError" class="rv__error" role="alert">{{ actionError }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
interface Resume {
id: number; name: string; source: string; job_id: number | null
text: string; struct_json: string | null; word_count: number
is_default: number; created_at: string; updated_at: string
}
const resumes = ref<Resume[]>([])
const selected = ref<Resume | null>(null)
const loading = ref(true)
const editing = ref(false)
const editName = ref('')
const editText = ref('')
const saving = ref(false)
const actionError = ref('')
const showDownloadMenu = ref(false)
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
async function loadList() {
loading.value = true
const { data } = await useApiFetch<{ resumes: Resume[] }>('/api/resumes')
resumes.value = data?.resumes ?? []
if (resumes.value.length && !selected.value) select(resumes.value[0])
loading.value = false
}
function select(r: Resume) {
selected.value = r
editing.value = false
editName.value = r.name
editText.value = r.text
actionError.value = ''
showDownloadMenu.value = false
}
function toggleEdit() {
if (editing.value) {
editing.value = false
editName.value = selected.value?.name ?? ''
editText.value = selected.value?.text ?? ''
} else {
editing.value = true
}
}
async function saveEdit() {
if (!selected.value) return
saving.value = true; actionError.value = ''
const { data, error } = await useApiFetch<Resume>(
`/api/resumes/${selected.value.id}`,
{ method: 'PATCH', body: JSON.stringify({ name: editName.value, text: editText.value }),
headers: { 'Content-Type': 'application/json' } }
)
saving.value = false
if (error || !data) { actionError.value = 'Save failed.'; return }
const idx = resumes.value.findIndex(r => r.id === data.id)
if (idx >= 0) resumes.value[idx] = data
selected.value = data
editing.value = false
}
async function setDefault() {
if (!selected.value) return
actionError.value = ''
const { error } = await useApiFetch(`/api/resumes/${selected.value.id}/set-default`, { method: 'POST' })
if (error) { actionError.value = 'Failed to set default.'; return }
await loadList()
if (selected.value) {
const refreshed = resumes.value.find(r => r.id === selected.value!.id)
if (refreshed) select(refreshed)
}
}
async function confirmDelete() {
if (!selected.value) return
if (!window.confirm(`Delete "${selected.value.name}"? This cannot be undone.`)) return
actionError.value = ''
const { error } = await useApiFetch(`/api/resumes/${selected.value.id}`, { method: 'DELETE' })
if (error) { actionError.value = 'Delete failed — check if this is the default resume.'; return }
selected.value = null
await loadList()
}
async function handleImport(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
actionError.value = ''
const form = new FormData()
form.append('file', file)
const { data, error } = await useApiFetch<Resume>('/api/resumes/import', { method: 'POST', body: form })
if (error || !data) { actionError.value = 'Import failed — check file format.'; return }
resumes.value = [data, ...resumes.value]
select(data)
}
function downloadTxt() {
if (!selected.value) return
const blob = new Blob([selected.value.text], { type: 'text/plain' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `${selected.value.name.replace(/\s+/g, '-')}.txt`
a.click()
URL.revokeObjectURL(a.href)
showDownloadMenu.value = false
}
function downloadPdf() {
if (!selected.value) return
window.open(`/api/resumes/${selected.value.id}/export-pdf`, '_blank')
showDownloadMenu.value = false
}
function downloadYaml() {
if (!selected.value) return
window.open(`/api/resumes/${selected.value.id}/export-yaml`, '_blank')
showDownloadMenu.value = false
}
onMounted(loadList)
</script>
<style scoped>
.rv { display: flex; flex-direction: column; gap: var(--space-4, 1rem); padding: var(--space-5, 1.25rem); height: 100%; }
.rv__header { display: flex; align-items: center; justify-content: space-between; }
.rv__title { font-size: var(--font-xl, 1.25rem); font-weight: 700; margin: 0; }
.rv__file-input { display: none; }
.rv__import-btn { cursor: pointer; }
.rv__layout { display: grid; grid-template-columns: 260px 1fr; gap: var(--space-4, 1rem); flex: 1; min-height: 0; }
.rv__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-1, 0.25rem); overflow-y: auto; }
.rv__list-item {
display: flex; align-items: flex-start; gap: var(--space-2, 0.5rem);
padding: var(--space-3, 0.75rem); border-radius: var(--radius-md, 0.5rem);
cursor: pointer; border: 1px solid transparent;
}
.rv__list-item:hover { background: var(--color-surface-alt, #f8fafc); }
.rv__list-item--active { background: var(--color-surface-alt, #f8fafc); border-color: var(--color-accent, #6366f1); }
.rv__item-star { color: var(--color-warning, #f59e0b); font-size: 1rem; flex-shrink: 0; margin-top: 2px; }
.rv__item-info { display: flex; flex-direction: column; gap: 2px; }
.rv__item-name { font-weight: 500; font-size: var(--font-sm, 0.875rem); }
.rv__item-meta { font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #64748b); }
.rv__item-source { font-size: var(--font-xs, 0.75rem); color: var(--color-accent, #6366f1); }
.rv__preview-pane { display: flex; flex-direction: column; gap: var(--space-3, 0.75rem); min-height: 0; }
.rv__preview-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3, 0.75rem); }
.rv__preview-meta { display: flex; align-items: center; gap: var(--space-2, 0.5rem); flex-wrap: wrap; }
.rv__preview-name { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; }
.rv__preview-words { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #64748b); }
.rv__default-badge {
font-size: var(--font-xs, 0.75rem); font-weight: 600;
background: var(--color-success, #16a34a); color: #fff;
padding: 2px 8px; border-radius: 9999px;
}
.rv__preview-actions { display: flex; gap: var(--space-2, 0.5rem); flex-wrap: wrap; align-items: center; }
.rv__delete-btn {
color: var(--color-error, #dc2626); background: none;
border: 1px solid var(--color-error, #dc2626);
border-radius: var(--radius-md, 0.5rem);
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
cursor: pointer; font-size: var(--font-sm, 0.875rem);
}
.rv__delete-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.rv__edit-name-input {
width: 100%; padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
border: 1px solid var(--color-border, #e2e8f0); border-radius: var(--radius-md, 0.5rem);
font-size: var(--font-base, 1rem);
}
.rv__textarea {
flex: 1; min-height: 400px; padding: var(--space-3, 0.75rem);
border: 1px solid var(--color-border, #e2e8f0); border-radius: var(--radius-md, 0.5rem);
font-family: monospace; font-size: var(--font-sm, 0.875rem); resize: vertical;
background: var(--color-surface-alt, #f8fafc);
}
.rv__textarea:not([readonly]) { background: var(--color-surface, #fff); }
.rv__edit-actions { display: flex; gap: var(--space-2, 0.5rem); }
.rv__error { color: var(--color-error, #dc2626); font-size: var(--font-sm, 0.875rem); }
.rv__download-menu { position: relative; }
.rv__download-dropdown {
position: absolute; top: 100%; right: 0; z-index: 10;
background: var(--color-surface, #fff); border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 0.5rem); list-style: none; margin: 4px 0; padding: var(--space-1, 0.25rem);
min-width: 140px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.rv__download-dropdown button {
width: 100%; text-align: left; background: none; border: none;
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
cursor: pointer; font-size: var(--font-sm, 0.875rem); border-radius: var(--radius-sm, 0.25rem);
}
.rv__download-dropdown button:hover { background: var(--color-surface-alt, #f8fafc); }
.rv__loading, .rv__empty { color: var(--color-text-muted, #64748b); font-size: var(--font-sm, 0.875rem); }
@media (max-width: 640px) {
.rv__layout { grid-template-columns: 1fr; }
.rv__list { max-height: 200px; }
}
</style>