Compare commits

..

14 commits

Author SHA1 Message Date
280f4271a5 feat: add Plausible analytics to Vue SPA and docs
Some checks failed
CI / Backend (Python) (push) Failing after 1m13s
CI / Frontend (Vue) (push) Failing after 20s
Mirror / mirror (push) Failing after 7s
2026-04-16 21:15:55 -07:00
1c9bfc9fb6 test: integration tests for resume library<->profile sync endpoints 2026-04-16 14:29:00 -07:00
22bc57242e feat: ResumeProfileView — career_summary, education, achievements sections and sync status label 2026-04-16 14:22:36 -07:00
9f984c22cb feat: resume store — add career_summary, education, achievements, lastSynced state
Extends the resume Pinia store with EducationEntry interface, four new
refs (career_summary, education, achievements, lastSynced), education
CRUD helpers, and load/save wiring for all new fields. lastSynced is
set to current ISO timestamp on successful save.
2026-04-16 14:15:07 -07:00
fe3e4ff539 feat: ResumesView — Apply to profile button, Active profile badge, sync notice, unsaved-changes guard 2026-04-16 14:13:44 -07:00
43599834d5 feat: ResumeSyncConfirmModal — before/after confirmation for profile sync 2026-04-16 14:11:37 -07:00
fe5371613e feat: extend PUT /api/settings/resume to sync content back to default library entry
When a default_resume_id is set in user.yaml, saving the resume profile
now calls profile_to_library and update_resume_content to keep the
library entry in sync. Returns {"ok": true, "synced_library_entry_id": <int|null>}.
2026-04-16 14:09:56 -07:00
369bf68399 feat: POST /api/resumes/{id}/apply-to-profile — library→profile sync with auto-backup 2026-04-16 14:06:52 -07:00
eef6c33d94 feat: add EducationEntry model, extend ResumePayload with education/achievements/career_summary
- Add EducationEntry Pydantic model (institution, degree, field, start_date, end_date)
- Extend ResumePayload with career_summary str, education List[EducationEntry], achievements List[str]
- Rewrite _normalize_experience to pass through Vue-native format (period/responsibilities keys) unchanged; AIHawk format (key_responsibilities/employment_period) still converted
- Extend GET /api/settings/resume to fall back to user.yaml for legacy career_summary when resume YAML is missing or the field is empty
2026-04-16 14:02:59 -07:00
53bfe6b326 feat: add update_resume_synced_at and update_resume_content db helpers
Expose synced_at in _resume_as_dict (with safe fallback for pre-migration
DBs), and add two new helpers: update_resume_synced_at (library→profile
direction) and update_resume_content (profile→library direction, updates
text/struct_json/word_count/synced_at/updated_at).
2026-04-16 13:14:10 -07:00
cd787a2509 fix: period split in profile_to_library handles ISO dates with hyphens
Fixes a bug where ISO-formatted dates (e.g. '2023-01 – 2025-03') in the
period field were split incorrectly. The old code replaced the en-dash with
a hyphen first, then split on the first hyphen, causing dates like '2023-01'
to be split into '2023' and '01' instead of the expected start/end pair.

The fix splits on the dash/dash separator *before* normalizing to plain
hyphens, ensuring round-trip conversion of dates with embedded hyphens.

Adds two regression tests:
- test_profile_to_library_period_split_iso_dates: verifies en-dash separation
- test_profile_to_library_period_split_em_dash: verifies em-dash separation
2026-04-16 13:11:22 -07:00
048a5f4cc3 feat: resume_sync.py — library↔profile transform functions with tests
Pure transform functions (no LLM, no DB) bridging the two resume
representations: library struct_json ↔ ResumePayload content fields.
Exports library_to_profile_content, profile_to_library,
make_auto_backup_name, blank_fields_on_import. 22 tests, all passing.
2026-04-16 13:04:56 -07:00
fe4947a72f feat: add synced_at column to resumes table (migration 007) 2026-04-16 12:58:00 -07:00
4e11cf3cfa fix: sanitize invalid JSON escape sequences from LLM output in resume optimizer
LLMs occasionally emit backslash sequences that are valid regex but not valid
JSON (e.g. \s, \d, \p). This caused extract_jd_signals() to fall through to
the exception handler, leaving llm_signals empty. With no LLM signals, the
optimizer fell back to TF-IDF only — which is more conservative and can
legitimately return zero gaps, making the UI appear to say the resume is fine.

Fix: strip bare backslashes not followed by a recognised JSON escape character
("  \  /  b  f  n  r  t  u) before parsing. Preserves \n, \", etc.

Reproduces: cover letter generation concurrent with gap analysis raises the
probability of a slightly malformed LLM response due to model load.
2026-04-16 11:11:50 -07:00
14 changed files with 1163 additions and 26 deletions

View file

@ -858,6 +858,80 @@ def set_default_resume_endpoint(resume_id: int):
return {"ok": True}
@app.post("/api/resumes/{resume_id}/apply-to-profile")
def apply_resume_to_profile(resume_id: int):
"""Sync a library resume entry to the active profile (library→profile direction).
Workflow:
1. Load the library entry (must have struct_json).
2. Load current profile to preserve metadata fields.
3. Backup current profile content as a new auto-named library entry.
4. Merge content fields from the library entry into the profile.
5. Write updated plain_text_resume.yaml.
6. Mark the library entry synced_at.
7. Return backup details for the frontend notification.
"""
import json as _json
from scripts.resume_sync import (
library_to_profile_content,
profile_to_library,
make_auto_backup_name,
)
from scripts.db import get_resume as _get, create_resume as _create
db_path = Path(_request_db.get() or DB_PATH)
entry = _get(db_path, resume_id)
if not entry:
raise HTTPException(404, "Resume not found")
struct_json: dict = {}
if entry.get("struct_json"):
try:
struct_json = _json.loads(entry["struct_json"])
except Exception:
raise HTTPException(422, "Library entry has malformed struct_json — re-import the resume to repair it.")
resume_path = _resume_path()
current_profile: dict = {}
if resume_path.exists():
with open(resume_path, encoding="utf-8") as f:
current_profile = yaml.safe_load(f) or {}
# Backup current content to library before overwriting
backup_text, backup_struct = profile_to_library(current_profile)
backup_name = make_auto_backup_name(entry["name"])
backup = _create(
db_path,
name=backup_name,
text=backup_text,
source="auto_backup",
struct_json=_json.dumps(backup_struct),
)
# Merge: overwrite content fields, preserve metadata
content = library_to_profile_content(struct_json)
CONTENT_FIELDS = {
"name", "surname", "email", "phone", "career_summary",
"experience", "skills", "education", "achievements",
}
for field in CONTENT_FIELDS:
current_profile[field] = content[field]
resume_path.parent.mkdir(parents=True, exist_ok=True)
with open(resume_path, "w", encoding="utf-8") as f:
yaml.dump(current_profile, f, allow_unicode=True, default_flow_style=False)
from scripts.db import update_resume_synced_at as _mark_synced
_mark_synced(db_path, resume_id)
return {
"ok": True,
"backup_id": backup["id"],
"backup_name": backup_name,
"fields_updated": sorted(CONTENT_FIELDS),
}
# ── Per-job resume endpoints ───────────────────────────────────────────────────
@app.get("/api/jobs/{job_id}/resume")
@ -2691,10 +2765,17 @@ class WorkEntry(BaseModel):
title: str = ""; company: str = ""; period: str = ""; location: str = ""
industry: str = ""; responsibilities: str = ""; skills: List[str] = []
class EducationEntry(BaseModel):
institution: str = ""; degree: str = ""; field: str = ""
start_date: str = ""; end_date: str = ""
class ResumePayload(BaseModel):
name: str = ""; email: str = ""; phone: str = ""; linkedin_url: str = ""
surname: str = ""; address: str = ""; city: str = ""; zip_code: str = ""; date_of_birth: str = ""
career_summary: str = ""
experience: List[WorkEntry] = []
education: List[EducationEntry] = []
achievements: List[str] = []
salary_min: int = 0; salary_max: int = 0; notice_period: str = ""
remote: bool = False; relocation: bool = False
assessment: bool = False; background_check: bool = False
@ -2722,32 +2803,46 @@ def _tokens_path() -> Path:
def _normalize_experience(raw: list) -> list:
"""Normalize AIHawk-style experience entries to the Vue WorkEntry schema.
Parser / AIHawk stores: bullets (list[str]), start_date, end_date
Vue WorkEntry expects: responsibilities (str), period (str)
AIHawk stores: key_responsibilities (numbered dicts), employment_period, skills_acquired
Vue WorkEntry: responsibilities (str), period (str), skills (list)
If already in Vue format (has 'period' key or 'responsibilities' key), pass through unchanged.
"""
out = []
for e in raw:
if not isinstance(e, dict):
continue
entry = dict(e)
# bullets → responsibilities
if "responsibilities" not in entry or not entry["responsibilities"]:
bullets = entry.pop("bullets", None) or []
if isinstance(bullets, list):
entry["responsibilities"] = "\n".join(b for b in bullets if b)
elif isinstance(bullets, str):
entry["responsibilities"] = bullets
# Already in Vue WorkEntry format — pass through
if "period" in e or "responsibilities" in e:
out.append({
"title": e.get("title", ""),
"company": e.get("company", ""),
"period": e.get("period", ""),
"location": e.get("location", ""),
"industry": e.get("industry", ""),
"responsibilities": e.get("responsibilities", ""),
"skills": e.get("skills") or [],
})
continue
# AIHawk format
resps = e.get("key_responsibilities", {})
if isinstance(resps, dict):
resp_text = "\n".join(v for v in resps.values() if isinstance(v, str))
elif isinstance(resps, list):
resp_text = "\n".join(str(r) for r in resps)
else:
entry.pop("bullets", None)
# start_date + end_date → period
if "period" not in entry or not entry["period"]:
start = entry.pop("start_date", "") or ""
end = entry.pop("end_date", "") or ""
entry["period"] = f"{start} {end}".strip(" ") if (start or end) else ""
else:
entry.pop("start_date", None)
entry.pop("end_date", None)
out.append(entry)
resp_text = str(resps)
period = e.get("employment_period", "")
skills_raw = e.get("skills_acquired", [])
skills = skills_raw if isinstance(skills_raw, list) else []
out.append({
"title": e.get("position", ""),
"company": e.get("company", ""),
"period": period,
"location": e.get("location", ""),
"industry": e.get("industry", ""),
"responsibilities": resp_text,
"skills": skills,
})
return out
@ -2756,24 +2851,58 @@ def get_resume():
try:
resume_path = _resume_path()
if not resume_path.exists():
# Backward compat: check user.yaml for career_summary
_uy = Path(_user_yaml_path())
if _uy.exists():
uy = yaml.safe_load(_uy.read_text(encoding="utf-8")) or {}
if uy.get("career_summary"):
return {"exists": False, "legacy_career_summary": uy["career_summary"]}
return {"exists": False}
with open(resume_path) as f:
with open(resume_path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
data["exists"] = True
if "experience" in data and isinstance(data["experience"], list):
data["experience"] = _normalize_experience(data["experience"])
# Backward compat: if career_summary missing from YAML, try user.yaml
if not data.get("career_summary"):
_uy = Path(_user_yaml_path())
if _uy.exists():
uy = yaml.safe_load(_uy.read_text(encoding="utf-8")) or {}
data["career_summary"] = uy.get("career_summary", "")
return data
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/settings/resume")
def save_resume(payload: ResumePayload):
"""Save resume profile. If a default library entry exists, sync content back to it."""
import json as _json
from scripts.db import (
get_resume as _get_resume,
update_resume_content as _update_content,
)
from scripts.resume_sync import profile_to_library
try:
resume_path = _resume_path()
resume_path.parent.mkdir(parents=True, exist_ok=True)
with open(resume_path, "w") as f:
with open(resume_path, "w", encoding="utf-8") as f:
yaml.dump(payload.model_dump(), f, allow_unicode=True, default_flow_style=False)
return {"ok": True}
# Profile→library sync: if a default resume exists, update it
synced_id: int | None = None
db_path = Path(_request_db.get() or DB_PATH)
_uy = Path(_user_yaml_path())
if _uy.exists():
profile_meta = yaml.safe_load(_uy.read_text(encoding="utf-8")) or {}
default_id = profile_meta.get("default_resume_id")
if default_id:
entry = _get_resume(db_path, int(default_id))
if entry:
text, struct = profile_to_library(payload.model_dump())
_update_content(db_path, int(default_id), text=text, struct_json=_json.dumps(struct))
synced_id = int(default_id)
return {"ok": True, "synced_library_entry_id": synced_id}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

1
docs/plausible.js Normal file
View file

@ -0,0 +1 @@
(function(){var s=document.createElement("script");s.defer=true;s.dataset.domain="docs.circuitforge.tech,circuitforge.tech";s.dataset.api="https://analytics.circuitforge.tech/api/event";s.src="https://analytics.circuitforge.tech/js/script.js";document.head.appendChild(s);})();

View file

@ -0,0 +1,3 @@
-- 007_resume_sync.sql
-- Add synced_at to resumes: ISO datetime of last library↔profile sync, null = never synced.
ALTER TABLE resumes ADD COLUMN synced_at TEXT;

View file

@ -70,3 +70,6 @@ nav:
- Tier System: reference/tier-system.md
- LLM Router: reference/llm-router.md
- Config Files: reference/config-files.md
extra_javascript:
- plausible.js

View file

@ -973,6 +973,7 @@ def _resume_as_dict(row) -> dict:
"is_default": row["is_default"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"synced_at": row["synced_at"] if "synced_at" in row.keys() else None,
}
@ -1074,6 +1075,44 @@ def set_default_resume(db_path: Path = DEFAULT_DB, resume_id: int = 0) -> None:
conn.close()
def update_resume_synced_at(db_path: Path = DEFAULT_DB, resume_id: int = 0) -> None:
"""Mark a library entry as synced to the profile (library→profile direction)."""
conn = sqlite3.connect(db_path)
try:
conn.execute(
"UPDATE resumes SET synced_at=datetime('now') WHERE id=?",
(resume_id,),
)
conn.commit()
finally:
conn.close()
def update_resume_content(
db_path: Path = DEFAULT_DB,
resume_id: int = 0,
text: str = "",
struct_json: str | None = None,
) -> None:
"""Update text, struct_json, and synced_at for a library entry.
Called by the profilelibrary sync path (PUT /api/settings/resume).
"""
word_count = len(text.split()) if text else 0
conn = sqlite3.connect(db_path)
try:
conn.execute(
"""UPDATE resumes
SET text=?, struct_json=?, word_count=?,
synced_at=datetime('now'), updated_at=datetime('now')
WHERE id=?""",
(text, struct_json, word_count, 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)

View file

@ -70,7 +70,12 @@ def extract_jd_signals(description: str, resume_text: str = "") -> list[str]:
# Extract JSON array from response (LLM may wrap it in markdown)
match = re.search(r"\[.*\]", raw, re.DOTALL)
if match:
llm_signals = json.loads(match.group(0))
json_str = match.group(0)
# LLMs occasionally emit invalid JSON escape sequences (e.g. \s, \d, \p)
# that are valid regex but not valid JSON. Replace bare backslashes that
# aren't followed by a recognised JSON escape character.
json_str = re.sub(r'\\([^"\\/bfnrtu])', r'\1', json_str)
llm_signals = json.loads(json_str)
llm_signals = [s.strip() for s in llm_signals if isinstance(s, str) and s.strip()]
except Exception:
log.warning("[resume_optimizer] LLM signal extraction failed", exc_info=True)

217
scripts/resume_sync.py Normal file
View file

@ -0,0 +1,217 @@
"""
Resume format transform library profile.
Converts between:
- Library format: struct_json produced by resume_parser.parse_resume()
{name, email, phone, career_summary, experience[{title,company,start_date,end_date,location,bullets[]}],
education[{institution,degree,field,start_date,end_date}], skills[], achievements[]}
- Profile content format: ResumePayload content fields (plain_text_resume.yaml)
{name, surname, email, phone, career_summary,
experience[{title,company,period,location,industry,responsibilities,skills[]}],
education[{institution,degree,field,start_date,end_date}],
skills[], achievements[]}
Profile metadata fields (salary, work prefs, self-ID, PII) are never touched here.
License: MIT
"""
from __future__ import annotations
from datetime import date
from typing import Any
_CONTENT_FIELDS = frozenset({
"name", "surname", "email", "phone", "career_summary",
"experience", "skills", "education", "achievements",
})
def library_to_profile_content(struct_json: dict[str, Any]) -> dict[str, Any]:
"""Transform a library struct_json to ResumePayload content fields.
Returns only content fields. Caller is responsible for merging with existing
metadata fields (salary, preferences, self-ID) so they are not overwritten.
Lossy for experience[].industry (always blank parser does not capture it).
name is split on first space into name/surname.
"""
full_name: str = struct_json.get("name") or ""
parts = full_name.split(" ", 1)
name = parts[0]
surname = parts[1] if len(parts) > 1 else ""
experience = []
for exp in struct_json.get("experience") or []:
start = (exp.get("start_date") or "").strip()
end = (exp.get("end_date") or "").strip()
if start and end:
period = f"{start} \u2013 {end}"
elif start:
period = start
elif end:
period = end
else:
period = ""
bullets: list[str] = exp.get("bullets") or []
responsibilities = "\n".join(b for b in bullets if b)
experience.append({
"title": exp.get("title") or "",
"company": exp.get("company") or "",
"period": period,
"location": exp.get("location") or "",
"industry": "", # not captured by parser
"responsibilities": responsibilities,
"skills": [],
})
education = []
for edu in struct_json.get("education") or []:
education.append({
"institution": edu.get("institution") or "",
"degree": edu.get("degree") or "",
"field": edu.get("field") or "",
"start_date": edu.get("start_date") or "",
"end_date": edu.get("end_date") or "",
})
return {
"name": name,
"surname": surname,
"email": struct_json.get("email") or "",
"phone": struct_json.get("phone") or "",
"career_summary": struct_json.get("career_summary") or "",
"experience": experience,
"skills": list(struct_json.get("skills") or []),
"education": education,
"achievements": list(struct_json.get("achievements") or []),
}
def profile_to_library(payload: dict[str, Any]) -> tuple[str, dict[str, Any]]:
"""Transform ResumePayload content fields to (plain_text, struct_json).
Inverse of library_to_profile_content. The plain_text is a best-effort
reconstruction for display and re-parsing. struct_json is the canonical
structured representation stored in the resumes table.
"""
name_parts = [payload.get("name") or "", payload.get("surname") or ""]
full_name = " ".join(p for p in name_parts if p).strip()
career_summary = (payload.get("career_summary") or "").strip()
lines: list[str] = []
if full_name:
lines.append(full_name)
email = payload.get("email") or ""
phone = payload.get("phone") or ""
if email:
lines.append(email)
if phone:
lines.append(phone)
if career_summary:
lines += ["", "SUMMARY", career_summary]
experience_structs = []
for exp in payload.get("experience") or []:
title = (exp.get("title") or "").strip()
company = (exp.get("company") or "").strip()
period = (exp.get("period") or "").strip()
location = (exp.get("location") or "").strip()
# Split period back to start_date / end_date.
# Split on the dash/dash separator BEFORE normalising to plain hyphens
# so that ISO dates like "2023-01 2025-03" round-trip correctly.
if "\u2013" in period: # en-dash
date_parts = [p.strip() for p in period.split("\u2013", 1)]
elif "\u2014" in period: # em-dash
date_parts = [p.strip() for p in period.split("\u2014", 1)]
else:
date_parts = [period.strip()] if period.strip() else []
start_date = date_parts[0] if date_parts else ""
end_date = date_parts[1] if len(date_parts) > 1 else ""
resp = (exp.get("responsibilities") or "").strip()
bullets = [b.strip() for b in resp.split("\n") if b.strip()]
if title or company:
header = " | ".join(p for p in [title, company, period] if p)
lines += ["", header]
if location:
lines.append(location)
for b in bullets:
lines.append(f"\u2022 {b}")
experience_structs.append({
"title": title,
"company": company,
"start_date": start_date,
"end_date": end_date,
"location": location,
"bullets": bullets,
})
skills: list[str] = list(payload.get("skills") or [])
if skills:
lines += ["", "SKILLS", ", ".join(skills)]
education_structs = []
for edu in payload.get("education") or []:
institution = (edu.get("institution") or "").strip()
degree = (edu.get("degree") or "").strip()
field = (edu.get("field") or "").strip()
start_date = (edu.get("start_date") or "").strip()
end_date = (edu.get("end_date") or "").strip()
if institution or degree:
label = " ".join(p for p in [degree, field] if p)
lines.append(f"{label} \u2014 {institution}" if institution else label)
education_structs.append({
"institution": institution,
"degree": degree,
"field": field,
"start_date": start_date,
"end_date": end_date,
})
achievements: list[str] = list(payload.get("achievements") or [])
struct_json: dict[str, Any] = {
"name": full_name,
"email": email,
"phone": phone,
"career_summary": career_summary,
"experience": experience_structs,
"skills": skills,
"education": education_structs,
"achievements": achievements,
}
plain_text = "\n".join(lines).strip()
return plain_text, struct_json
def make_auto_backup_name(source_name: str) -> str:
"""Generate a timestamped auto-backup name.
Example: "Auto-backup before Senior Engineer Resume — 2026-04-16"
"""
today = date.today().isoformat()
return f"Auto-backup before {source_name} \u2014 {today}"
def blank_fields_on_import(struct_json: dict[str, Any]) -> list[str]:
"""Return content field names that will be blank after a library→profile import.
Used to warn the user in the confirmation modal so they know what to fill in.
"""
blank: list[str] = []
if struct_json.get("experience"):
# industry is always blank — parser never captures it
blank.append("experience[].industry")
# location may be blank for some entries
if any(not (e.get("location") or "").strip() for e in struct_json["experience"]):
blank.append("experience[].location")
return blank

207
tests/test_resume_sync.py Normal file
View file

@ -0,0 +1,207 @@
"""Unit tests for scripts.resume_sync — format transform between library and profile."""
import json
import pytest
from scripts.resume_sync import (
library_to_profile_content,
profile_to_library,
make_auto_backup_name,
blank_fields_on_import,
)
# ── Fixtures ──────────────────────────────────────────────────────────────────
STRUCT_JSON = {
"name": "Alex Rivera",
"email": "alex@example.com",
"phone": "555-0100",
"career_summary": "Senior UX Designer with 6 years experience.",
"experience": [
{
"title": "Senior UX Designer",
"company": "StreamNote",
"start_date": "2023",
"end_date": "present",
"location": "Remote",
"bullets": ["Led queue redesign", "Built component library"],
}
],
"education": [
{
"institution": "State University",
"degree": "B.F.A.",
"field": "Graphic Design",
"start_date": "2015",
"end_date": "2019",
}
],
"skills": ["Figma", "User Research"],
"achievements": ["Design award 2024"],
}
PROFILE_PAYLOAD = {
"name": "Alex",
"surname": "Rivera",
"email": "alex@example.com",
"phone": "555-0100",
"career_summary": "Senior UX Designer with 6 years experience.",
"experience": [
{
"title": "Senior UX Designer",
"company": "StreamNote",
"period": "2023 present",
"location": "Remote",
"industry": "",
"responsibilities": "Led queue redesign\nBuilt component library",
"skills": [],
}
],
"education": [
{
"institution": "State University",
"degree": "B.F.A.",
"field": "Graphic Design",
"start_date": "2015",
"end_date": "2019",
}
],
"skills": ["Figma", "User Research"],
"achievements": ["Design award 2024"],
}
# ── library_to_profile_content ────────────────────────────────────────────────
def test_library_to_profile_splits_name():
result = library_to_profile_content(STRUCT_JSON)
assert result["name"] == "Alex"
assert result["surname"] == "Rivera"
def test_library_to_profile_single_word_name():
result = library_to_profile_content({**STRUCT_JSON, "name": "Cher"})
assert result["name"] == "Cher"
assert result["surname"] == ""
def test_library_to_profile_email_phone():
result = library_to_profile_content(STRUCT_JSON)
assert result["email"] == "alex@example.com"
assert result["phone"] == "555-0100"
def test_library_to_profile_career_summary():
result = library_to_profile_content(STRUCT_JSON)
assert result["career_summary"] == "Senior UX Designer with 6 years experience."
def test_library_to_profile_experience_period():
result = library_to_profile_content(STRUCT_JSON)
assert result["experience"][0]["period"] == "2023 present"
def test_library_to_profile_experience_bullets_joined():
result = library_to_profile_content(STRUCT_JSON)
assert result["experience"][0]["responsibilities"] == "Led queue redesign\nBuilt component library"
def test_library_to_profile_experience_industry_blank():
result = library_to_profile_content(STRUCT_JSON)
assert result["experience"][0]["industry"] == ""
def test_library_to_profile_education():
result = library_to_profile_content(STRUCT_JSON)
assert result["education"][0]["institution"] == "State University"
assert result["education"][0]["degree"] == "B.F.A."
def test_library_to_profile_skills():
result = library_to_profile_content(STRUCT_JSON)
assert result["skills"] == ["Figma", "User Research"]
def test_library_to_profile_achievements():
result = library_to_profile_content(STRUCT_JSON)
assert result["achievements"] == ["Design award 2024"]
def test_library_to_profile_missing_fields_no_keyerror():
result = library_to_profile_content({})
assert result["name"] == ""
assert result["experience"] == []
assert result["education"] == []
assert result["skills"] == []
assert result["achievements"] == []
# ── profile_to_library ────────────────────────────────────────────────────────
def test_profile_to_library_full_name():
text, struct = profile_to_library(PROFILE_PAYLOAD)
assert struct["name"] == "Alex Rivera"
def test_profile_to_library_experience_bullets_reconstructed():
_, struct = profile_to_library(PROFILE_PAYLOAD)
assert struct["experience"][0]["bullets"] == ["Led queue redesign", "Built component library"]
def test_profile_to_library_period_split():
_, struct = profile_to_library(PROFILE_PAYLOAD)
assert struct["experience"][0]["start_date"] == "2023"
assert struct["experience"][0]["end_date"] == "present"
def test_profile_to_library_period_split_iso_dates():
"""ISO dates (with hyphens) must round-trip through the period field correctly."""
payload = {
**PROFILE_PAYLOAD,
"experience": [{
**PROFILE_PAYLOAD["experience"][0],
"period": "2023-01 \u2013 2025-03",
}],
}
_, struct = profile_to_library(payload)
assert struct["experience"][0]["start_date"] == "2023-01"
assert struct["experience"][0]["end_date"] == "2025-03"
def test_profile_to_library_period_split_em_dash():
"""Em-dash separator is also handled."""
payload = {
**PROFILE_PAYLOAD,
"experience": [{
**PROFILE_PAYLOAD["experience"][0],
"period": "2022-06 \u2014 2023-12",
}],
}
_, struct = profile_to_library(payload)
assert struct["experience"][0]["start_date"] == "2022-06"
assert struct["experience"][0]["end_date"] == "2023-12"
def test_profile_to_library_education_round_trip():
_, struct = profile_to_library(PROFILE_PAYLOAD)
assert struct["education"][0]["institution"] == "State University"
def test_profile_to_library_plain_text_contains_name():
text, _ = profile_to_library(PROFILE_PAYLOAD)
assert "Alex Rivera" in text
def test_profile_to_library_plain_text_contains_summary():
text, _ = profile_to_library(PROFILE_PAYLOAD)
assert "Senior UX Designer" in text
def test_profile_to_library_empty_payload_no_crash():
text, struct = profile_to_library({})
assert isinstance(text, str)
assert isinstance(struct, dict)
# ── make_auto_backup_name ─────────────────────────────────────────────────────
def test_backup_name_format():
name = make_auto_backup_name("Senior Engineer Resume")
import re
assert re.match(r"Auto-backup before Senior Engineer Resume — \d{4}-\d{2}-\d{2}", name)
# ── blank_fields_on_import ────────────────────────────────────────────────────
def test_blank_fields_industry_always_listed():
result = blank_fields_on_import(STRUCT_JSON)
assert "experience[].industry" in result
def test_blank_fields_location_listed_when_missing():
no_loc = {**STRUCT_JSON, "experience": [{**STRUCT_JSON["experience"][0], "location": ""}]}
result = blank_fields_on_import(no_loc)
assert "experience[].location" in result
def test_blank_fields_location_not_listed_when_present():
result = blank_fields_on_import(STRUCT_JSON)
assert "experience[].location" not in result

View file

@ -0,0 +1,134 @@
"""Integration tests for resume library<->profile sync endpoints."""
import json
import os
from pathlib import Path
import pytest
import yaml
from fastapi.testclient import TestClient
from scripts.db import create_resume, get_resume, list_resumes
from scripts.db_migrate import migrate_db
STRUCT_JSON = {
"name": "Alex Rivera",
"email": "alex@example.com",
"phone": "555-0100",
"career_summary": "Senior UX Designer.",
"experience": [{"title": "Designer", "company": "Acme", "start_date": "2022",
"end_date": "present", "location": "Remote", "bullets": ["Led redesign"]}],
"education": [{"institution": "State U", "degree": "B.A.", "field": "Design",
"start_date": "2016", "end_date": "2020"}],
"skills": ["Figma"],
"achievements": ["Design award"],
}
@pytest.fixture
def fresh_db(tmp_path, monkeypatch):
"""Set up a fresh isolated DB + config dir, wired to dev_api._request_db."""
db = tmp_path / "test.db"
cfg = tmp_path / "config"
cfg.mkdir()
# STAGING_DB drives _user_yaml_path() -> dirname(db)/config/user.yaml
monkeypatch.setenv("STAGING_DB", str(db))
migrate_db(db)
import dev_api
monkeypatch.setattr(
dev_api,
"_request_db",
type("CV", (), {"get": lambda self: str(db), "set": lambda *a: None})(),
)
return db, cfg
def test_apply_to_profile_updates_yaml(fresh_db, monkeypatch):
db, cfg = fresh_db
import dev_api
client = TestClient(dev_api.app)
entry = create_resume(db, name="Test Resume",
text="Alex Rivera\n", source="uploaded",
struct_json=json.dumps(STRUCT_JSON))
resp = client.post(f"/api/resumes/{entry['id']}/apply-to-profile")
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert "backup_id" in data
assert "Auto-backup before Test Resume" in data["backup_name"]
profile_yaml = cfg / "plain_text_resume.yaml"
assert profile_yaml.exists()
profile = yaml.safe_load(profile_yaml.read_text())
assert profile["career_summary"] == "Senior UX Designer."
# Name split: "Alex Rivera" -> name="Alex", surname="Rivera"
assert profile["name"] == "Alex"
assert profile["surname"] == "Rivera"
assert profile["education"][0]["institution"] == "State U"
def test_apply_to_profile_creates_backup(fresh_db, monkeypatch):
db, cfg = fresh_db
profile_path = cfg / "plain_text_resume.yaml"
profile_path.write_text(yaml.dump({"name": "Old Name", "career_summary": "Old summary"}))
entry = create_resume(db, name="New Resume",
text="Alex Rivera\n", source="uploaded",
struct_json=json.dumps(STRUCT_JSON))
import dev_api
client = TestClient(dev_api.app)
client.post(f"/api/resumes/{entry['id']}/apply-to-profile")
resumes = list_resumes(db_path=db)
backup = next((r for r in resumes if r["source"] == "auto_backup"), None)
assert backup is not None
def test_apply_to_profile_preserves_metadata(fresh_db, monkeypatch):
db, cfg = fresh_db
profile_path = cfg / "plain_text_resume.yaml"
profile_path.write_text(yaml.dump({
"name": "Old", "salary_min": 80000, "salary_max": 120000,
"remote": True, "gender": "non-binary",
}))
entry = create_resume(db, name="New",
text="Alex\n", source="uploaded",
struct_json=json.dumps(STRUCT_JSON))
import dev_api
client = TestClient(dev_api.app)
client.post(f"/api/resumes/{entry['id']}/apply-to-profile")
profile = yaml.safe_load(profile_path.read_text())
assert profile["salary_min"] == 80000
assert profile["remote"] is True
assert profile["gender"] == "non-binary"
def test_save_resume_syncs_to_default_library_entry(fresh_db, monkeypatch):
db, cfg = fresh_db
entry = create_resume(db, name="My Resume",
text="Original", source="manual")
user_yaml = cfg / "user.yaml"
user_yaml.write_text(yaml.dump({"default_resume_id": entry["id"], "wizard_complete": True}))
import dev_api
client = TestClient(dev_api.app)
resp = client.put("/api/settings/resume", json={
"name": "Alex", "career_summary": "Updated summary",
"experience": [], "education": [], "achievements": [], "skills": [],
})
assert resp.status_code == 200
data = resp.json()
assert data["synced_library_entry_id"] == entry["id"]
updated = get_resume(db_path=db, resume_id=entry["id"])
assert updated["synced_at"] is not None
struct = json.loads(updated["struct_json"])
assert struct["career_summary"] == "Updated summary"
def test_save_resume_no_default_no_crash(fresh_db, monkeypatch):
db, cfg = fresh_db
user_yaml = cfg / "user.yaml"
user_yaml.write_text(yaml.dump({"wizard_complete": True}))
import dev_api
client = TestClient(dev_api.app)
resp = client.put("/api/settings/resume", json={
"name": "Alex", "career_summary": "", "experience": [],
"education": [], "achievements": [], "skills": [],
})
assert resp.status_code == 200
assert resp.json()["synced_library_entry_id"] is None

View file

@ -11,6 +11,9 @@
html, body { margin: 0; background: #eaeff8; min-height: 100vh; }
@media (prefers-color-scheme: dark) { html, body { background: #16202e; } }
</style>
<!-- Plausible analytics: cookie-free, GDPR-compliant, self-hosted.
Skips localhost/127.0.0.1. Reports to hostname + circuitforge.tech rollup. -->
<script>(function(){if(/localhost|127\.0\.0\.1/.test(location.hostname))return;var s=document.createElement('script');s.defer=true;s.dataset.domain=location.hostname+',circuitforge.tech';s.dataset.api='https://analytics.circuitforge.tech/api/event';s.src='https://analytics.circuitforge.tech/js/script.js';document.head.appendChild(s);})();</script>
</head>
<body>
<!-- Mount target only — App.vue root must NOT use id="app". Gotcha #1. -->

View file

@ -0,0 +1,146 @@
<template>
<Teleport to="body">
<div v-if="show" class="sync-modal__overlay" role="dialog" aria-modal="true"
aria-labelledby="sync-modal-title" @keydown.esc="$emit('cancel')">
<div class="sync-modal">
<h2 id="sync-modal-title" class="sync-modal__title">Replace profile content?</h2>
<div class="sync-modal__comparison">
<div class="sync-modal__col sync-modal__col--before">
<div class="sync-modal__col-label">Current profile</div>
<div class="sync-modal__col-name">{{ currentSummary.name || '(no name)' }}</div>
<div class="sync-modal__col-summary">{{ currentSummary.careerSummary || '(no summary)' }}</div>
<div class="sync-modal__col-role">{{ currentSummary.latestRole || '(no experience)' }}</div>
</div>
<div class="sync-modal__arrow" aria-hidden="true"></div>
<div class="sync-modal__col sync-modal__col--after">
<div class="sync-modal__col-label">Replacing with</div>
<div class="sync-modal__col-name">{{ sourceSummary.name || '(no name)' }}</div>
<div class="sync-modal__col-summary">{{ sourceSummary.careerSummary || '(no summary)' }}</div>
<div class="sync-modal__col-role">{{ sourceSummary.latestRole || '(no experience)' }}</div>
</div>
</div>
<div v-if="blankFields.length" class="sync-modal__blank-warning">
<strong>Fields that will be blank after import:</strong>
<ul>
<li v-for="f in blankFields" :key="f">{{ f }}</li>
</ul>
<p class="sync-modal__blank-note">You can fill these in after importing.</p>
</div>
<p class="sync-modal__preserve-note">
Your salary, work preferences, and contact details are not affected.
</p>
<div class="sync-modal__actions">
<button class="btn-secondary" @click="$emit('cancel')">Keep current profile</button>
<button class="btn-danger" @click="$emit('confirm')">Replace profile content</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
interface ContentSummary {
name: string
careerSummary: string
latestRole: string
}
defineProps<{
show: boolean
currentSummary: ContentSummary
sourceSummary: ContentSummary
blankFields: string[]
}>()
defineEmits<{
confirm: []
cancel: []
}>()
</script>
<style scoped>
.sync-modal__overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
padding: var(--space-4);
}
.sync-modal {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg, 0.75rem);
padding: var(--space-6);
max-width: 600px; width: 100%;
max-height: 90vh; overflow-y: auto;
}
.sync-modal__title {
font-size: 1.15rem; font-weight: 700;
margin-bottom: var(--space-5);
color: var(--color-text);
}
.sync-modal__comparison {
display: grid; grid-template-columns: 1fr auto 1fr; gap: var(--space-3);
align-items: start; margin-bottom: var(--space-5);
}
.sync-modal__arrow {
font-size: 1.5rem; color: var(--color-text-muted);
padding-top: var(--space-5);
}
.sync-modal__col {
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
}
.sync-modal__col--after { border-color: var(--color-primary); }
.sync-modal__col-label {
font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted);
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: var(--space-2);
}
.sync-modal__col-name { font-weight: 600; color: var(--color-text); margin-bottom: var(--space-1); }
.sync-modal__col-summary {
font-size: 0.82rem; color: var(--color-text-muted);
overflow: hidden; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-box-orient: vertical;
margin-bottom: var(--space-1);
}
.sync-modal__col-role { font-size: 0.82rem; color: var(--color-text-muted); font-style: italic; }
.sync-modal__blank-warning {
background: color-mix(in srgb, var(--color-warning, #d97706) 10%, var(--color-surface-alt));
border: 1px solid color-mix(in srgb, var(--color-warning, #d97706) 30%, var(--color-border));
border-radius: var(--radius-md); padding: var(--space-3);
margin-bottom: var(--space-4);
font-size: 0.85rem;
}
.sync-modal__blank-warning ul { margin: var(--space-2) 0 0 var(--space-4); }
.sync-modal__blank-note { margin-top: var(--space-2); color: var(--color-text-muted); }
.sync-modal__preserve-note {
font-size: 0.82rem; color: var(--color-text-muted);
margin-bottom: var(--space-5);
}
.sync-modal__actions {
display: flex; gap: var(--space-3); justify-content: flex-end; flex-wrap: wrap;
}
.btn-danger {
padding: var(--space-2) var(--space-4);
background: var(--color-error, #dc2626);
color: #fff; border: none;
border-radius: var(--radius-md); cursor: pointer;
font-size: var(--font-sm); font-weight: 600;
}
.btn-danger:hover { filter: brightness(1.1); }
.btn-secondary {
padding: var(--space-2) var(--space-4);
background: transparent;
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-md); cursor: pointer;
font-size: var(--font-sm);
}
.btn-secondary:hover { background: var(--color-surface-alt); }
</style>

View file

@ -8,6 +8,12 @@ export interface WorkEntry {
industry: string; responsibilities: string; skills: string[]
}
export interface EducationEntry {
id: string
institution: string; degree: string; field: string
start_date: string; end_date: string
}
export const useResumeStore = defineStore('settings/resume', () => {
const hasResume = ref(false)
const loading = ref(false)
@ -31,6 +37,11 @@ export const useResumeStore = defineStore('settings/resume', () => {
const veteran_status = ref(''); const disability = ref('')
// Keywords
const skills = ref<string[]>([]); const domains = ref<string[]>([]); const keywords = ref<string[]>([])
// Extended profile fields
const career_summary = ref('')
const education = ref<EducationEntry[]>([])
const achievements = ref<string[]>([])
const lastSynced = ref<string | null>(null)
// LLM suggestions (pending, not yet accepted)
const skillSuggestions = ref<string[]>([])
const domainSuggestions = ref<string[]>([])
@ -69,6 +80,9 @@ export const useResumeStore = defineStore('settings/resume', () => {
skills.value = (data.skills as string[]) ?? []
domains.value = (data.domains as string[]) ?? []
keywords.value = (data.keywords as string[]) ?? []
career_summary.value = String(data.career_summary ?? '')
education.value = ((data.education as Omit<EducationEntry, 'id'>[]) ?? []).map(e => ({ ...e, id: crypto.randomUUID() }))
achievements.value = (data.achievements as string[]) ?? []
}
async function save() {
@ -84,12 +98,19 @@ export const useResumeStore = defineStore('settings/resume', () => {
gender: gender.value, pronouns: pronouns.value, ethnicity: ethnicity.value,
veteran_status: veteran_status.value, disability: disability.value,
skills: skills.value, domains: domains.value, keywords: keywords.value,
career_summary: career_summary.value,
education: education.value.map(({ id: _id, ...e }) => e),
achievements: achievements.value,
}
const { error } = await useApiFetch('/api/settings/resume', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
saving.value = false
if (error) saveError.value = 'Save failed — please try again.'
if (error) {
saveError.value = 'Save failed — please try again.'
} else {
lastSynced.value = new Date().toISOString()
}
}
async function createBlank() {
@ -105,6 +126,16 @@ export const useResumeStore = defineStore('settings/resume', () => {
experience.value.splice(idx, 1)
}
function addEducation() {
education.value.push({
id: crypto.randomUUID(), institution: '', degree: '', field: '', start_date: '', end_date: ''
})
}
function removeEducation(idx: number) {
education.value.splice(idx, 1)
}
async function suggestTags(field: 'skills' | 'domains' | 'keywords') {
suggestingField.value = field
const current = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
@ -149,7 +180,8 @@ export const useResumeStore = defineStore('settings/resume', () => {
gender, pronouns, ethnicity, veteran_status, disability,
skills, domains, keywords,
skillSuggestions, domainSuggestions, keywordSuggestions, suggestingField,
career_summary, education, achievements, lastSynced,
syncFromProfile, load, save, createBlank,
addExperience, removeExperience, addTag, removeTag, suggestTags, acceptTagSuggestion,
addExperience, removeExperience, addEducation, removeEducation, addTag, removeTag, suggestTags, acceptTagSuggestion,
}
})

View file

@ -33,6 +33,7 @@
</span>
<div class="rv__item-info">
<span class="rv__item-name">{{ r.name }}</span>
<span v-if="r.is_default" class="rv__active-badge">Active profile</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>
@ -51,6 +52,11 @@
<button v-if="!selected.is_default" class="btn-secondary" @click="setDefault">
Set as Default
</button>
<button class="btn-generate" @click="applyToProfile"
:disabled="syncApplying"
aria-describedby="apply-to-profile-desc">
{{ syncApplying ? 'Applying…' : '⇩ Apply to profile' }}
</button>
<button class="btn-secondary" @click="toggleEdit">
{{ editing ? 'Cancel' : 'Edit' }}
</button>
@ -90,20 +96,50 @@
<button class="btn-secondary" @click="toggleEdit">Discard</button>
</div>
<p id="apply-to-profile-desc" class="rv__sync-desc">
Replaces your resume profile content with this version. Your current profile is backed up first.
</p>
<p v-if="selected.synced_at" class="rv__synced-at">
Last synced to profile: {{ fmtDate(selected.synced_at) }}
</p>
<p v-if="actionError" class="rv__error" role="alert">{{ actionError }}</p>
</div>
</div>
</div>
<!-- Persistent sync notice (dismissible) -->
<div v-if="syncNotice" class="rv__sync-notice" role="status" aria-live="polite">
Profile updated. Previous content backed up as
<strong>{{ syncNotice.backupName }}</strong>.
<button class="rv__sync-notice-dismiss" @click="dismissSyncNotice" aria-label="Dismiss"></button>
</div>
<ResumeSyncConfirmModal
:show="showSyncModal"
:current-summary="buildSummary(resumes.find(r => r.is_default === 1) ?? null)"
:source-summary="buildSummary(selected)"
:blank-fields="selected?.struct_json
? (JSON.parse(selected.struct_json).experience?.length
? ['experience[].industry']
: [])
: []"
@confirm="confirmApplyToProfile"
@cancel="showSyncModal = false"
/>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useApiFetch } from '../composables/useApi'
import ResumeSyncConfirmModal from '../components/ResumeSyncConfirmModal.vue'
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
synced_at: string | null
}
const resumes = ref<Resume[]>([])
@ -116,6 +152,25 @@ const saving = ref(false)
const actionError = ref('')
const showDownloadMenu = ref(false)
const showSyncModal = ref(false)
const syncApplying = ref(false)
const syncNotice = ref<{ backupName: string; backupId: number } | null>(null)
interface ContentSummary { name: string; careerSummary: string; latestRole: string }
function buildSummary(r: Resume | null): ContentSummary {
if (!r) return { name: '', careerSummary: '', latestRole: '' }
try {
const s = r.struct_json ? JSON.parse(r.struct_json) : {}
const exp = Array.isArray(s.experience) ? s.experience[0] : null
return {
name: s.name || r.name,
careerSummary: (s.career_summary || '').slice(0, 120),
latestRole: exp ? `${exp.title || ''} at ${exp.company || ''}`.replace(/^ at | at $/, '') : '',
}
} catch { return { name: r.name, careerSummary: '', latestRole: '' } }
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
@ -185,6 +240,30 @@ async function confirmDelete() {
await loadList()
}
async function applyToProfile() {
if (!selected.value) return
showSyncModal.value = true
}
async function confirmApplyToProfile() {
if (!selected.value) return
showSyncModal.value = false
syncApplying.value = true
actionError.value = ''
const { data, error } = await useApiFetch<{
ok: boolean; backup_id: number; backup_name: string
}>(`/api/resumes/${selected.value.id}/apply-to-profile`, { method: 'POST' })
syncApplying.value = false
if (error || !data?.ok) {
actionError.value = 'Profile sync failed — please try again.'
return
}
syncNotice.value = { backupName: data.backup_name, backupId: data.backup_id }
await loadList()
}
function dismissSyncNotice() { syncNotice.value = null }
async function handleImport(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
@ -221,6 +300,15 @@ function downloadYaml() {
}
onMounted(loadList)
onBeforeRouteLeave(() => {
if (editing.value && (editName.value !== selected.value?.name || editText.value !== selected.value?.text)) {
const confirmed = window.confirm(
`You have unsaved edits to "${selected.value?.name}". Leave without saving?`
)
if (!confirmed) return false
}
})
</script>
<style scoped>
@ -337,4 +425,32 @@ onMounted(loadList)
.rv__layout { grid-template-columns: 1fr; }
.rv__list { max-height: 200px; }
}
.rv__active-badge {
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-surface-alt));
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, var(--color-border));
border-radius: var(--radius-sm, 0.25rem);
padding: 1px 6px; margin-left: var(--space-1);
}
.rv__sync-desc {
font-size: 0.78rem; color: var(--color-text-muted); margin-top: var(--space-1);
}
.rv__synced-at {
font-size: 0.78rem; color: var(--color-text-muted); margin-top: var(--space-1);
}
.rv__sync-notice {
position: fixed; bottom: var(--space-6); left: 50%; transform: translateX(-50%);
background: var(--color-surface-raised);
border: 1px solid var(--color-primary);
border-radius: var(--radius-md); padding: var(--space-3) var(--space-5);
font-size: 0.9rem; z-index: 500; max-width: 480px;
display: flex; gap: var(--space-3); align-items: center;
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
}
.rv__sync-notice-dismiss {
background: none; border: none; cursor: pointer;
color: var(--color-text-muted); font-size: 1rem; flex-shrink: 0;
}
</style>

View file

@ -56,6 +56,23 @@
<p v-if="uploadError" class="error">{{ uploadError }}</p>
</section>
<!-- Sync status label -->
<div v-if="store.lastSynced" class="sync-status-label">
Content synced from Resume Library {{ fmtDate(store.lastSynced) }}.
Changes here update the default library entry when you save.
</div>
<!-- Career Summary -->
<section class="form-section">
<h3>Career Summary</h3>
<p class="section-note">Used in cover letter generation and as your professional introduction.</p>
<div class="field-row">
<label for="career-summary">Career summary</label>
<textarea id="career-summary" v-model="store.career_summary"
rows="4" placeholder="2-3 sentences summarising your background and what you bring."></textarea>
</div>
</section>
<!-- Personal Information -->
<section class="form-section">
<h3>Personal Information</h3>
@ -130,6 +147,57 @@
<button @click="store.addExperience()">+ Add Position</button>
</section>
<!-- Education -->
<section class="form-section">
<h3>Education</h3>
<div v-for="(edu, idx) in store.education" :key="edu.id" class="experience-card">
<div class="experience-card__header">
<span class="experience-card__label">Education {{ idx + 1 }}</span>
<button class="btn-remove" @click="store.removeEducation(idx)"
:aria-label="`Remove education entry ${idx + 1}`">Remove</button>
</div>
<div class="field-row">
<label>Institution</label>
<input v-model="edu.institution" placeholder="University or school name" />
</div>
<div class="field-row-grid">
<div class="field-row">
<label>Degree</label>
<input v-model="edu.degree" placeholder="e.g. B.S., M.A., Ph.D." />
</div>
<div class="field-row">
<label>Field of study</label>
<input v-model="edu.field" placeholder="e.g. Computer Science" />
</div>
</div>
<div class="field-row-grid">
<div class="field-row">
<label>Start year</label>
<input v-model="edu.start_date" placeholder="2015" />
</div>
<div class="field-row">
<label>End year</label>
<input v-model="edu.end_date" placeholder="2019" />
</div>
</div>
</div>
<button class="btn-secondary" @click="store.addEducation">+ Add education</button>
</section>
<!-- Achievements -->
<section class="form-section">
<h3>Achievements</h3>
<p class="section-note">Awards, certifications, open-source projects, publications.</p>
<div v-for="(ach, idx) in store.achievements" :key="idx" class="achievement-row">
<input :value="ach"
@input="store.achievements[idx] = ($event.target as HTMLInputElement).value"
placeholder="Describe the achievement" />
<button class="btn-remove" @click="store.achievements.splice(idx, 1)"
:aria-label="`Remove achievement ${idx + 1}`">&#x2715;</button>
</div>
<button class="btn-secondary" @click="store.achievements.push('')">+ Add achievement</button>
</section>
<!-- Preferences -->
<section class="form-section">
<h3>Preferences & Availability</h3>
@ -302,6 +370,10 @@ function handleFileSelect(event: Event) {
uploadError.value = null
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
async function handleUpload() {
const file = pendingFile.value
if (!file) return
@ -407,4 +479,34 @@ h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border); border-radius: 4px; color: var(--color-text-muted); cursor: pointer; font-size: 0.78rem; }
.loading { text-align: center; padding: var(--space-8); color: var(--color-text-muted); }
.replace-section { background: var(--color-surface-alt); border-radius: 8px; padding: var(--space-4); }
.sync-status-label {
font-size: 0.82rem; color: var(--color-text-muted);
border-left: 3px solid var(--color-primary);
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-6);
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface-alt));
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.achievement-row {
display: flex; gap: var(--space-2); align-items: center; margin-bottom: var(--space-2);
}
.achievement-row input { flex: 1; }
.btn-remove {
background: none; border: 1px solid var(--color-border);
border-radius: var(--radius-sm); padding: 2px var(--space-2);
cursor: pointer; color: var(--color-text-muted); font-size: 0.8rem;
white-space: nowrap;
}
.btn-remove:hover { color: var(--color-error, #dc2626); border-color: var(--color-error, #dc2626); }
.field-row-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
.btn-secondary {
padding: 7px 16px; background: transparent;
border: 1px solid var(--color-border); border-radius: 6px;
color: var(--color-text-muted); cursor: pointer; font-size: 0.85rem;
}
.btn-secondary:hover { border-color: var(--color-accent); color: var(--color-accent); }
.experience-card__header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-3);
}
.experience-card__label { font-size: 0.82rem; color: var(--color-text-muted); font-weight: 500; }
</style>