Compare commits

..

No commits in common. "797032bd9779fd22e21dc0fe41ed26553ffaad67" and "8e36863a49c9770d985be4393ff4f39ef087479a" have entirely different histories.

15 changed files with 82 additions and 1378 deletions

35
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: sudo apt-get update -q && sudo apt-get install -y libsqlcipher-dev
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- name: Configure git credentials for Forgejo
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
run: |
git config --global url."https://oauth2:${FORGEJO_TOKEN}@git.opensourcesolarpunk.com/".insteadOf "https://git.opensourcesolarpunk.com/"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest tests/ -v --tb=short

3
.gitignore vendored
View file

@ -40,11 +40,8 @@ pytest-output.txt
docs/superpowers/
data/email_score.jsonl
data/email_score.jsonl.bad-labels
data/email_label_queue.jsonl
data/email_compare_sample.jsonl
data/.feedback_ratelimit.json
data/config/
config/label_tool.yaml
config/server.yaml

View file

@ -6,7 +6,7 @@ WORKDIR /app
# System deps for companyScraper (beautifulsoup4, fake-useragent, lxml) and PDF gen
# libsqlcipher-dev: required to build pysqlcipher3 (SQLCipher AES-256 encryption for cloud mode)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libffi-dev curl libsqlcipher-dev git \
gcc libffi-dev curl libsqlcipher-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .

View file

@ -16,7 +16,7 @@ import subprocess
import sys
import threading
from contextvars import ContextVar
from datetime import datetime, timezone
from datetime import datetime
from pathlib import Path
from typing import Optional, List
from urllib.parse import urlparse
@ -58,20 +58,6 @@ async def lifespan(app: FastAPI):
_load_env(PEREGRINE_ROOT / ".env")
from scripts.db_migrate import migrate_db
migrate_db(Path(DB_PATH))
# Cloud mode: sweep all known user DBs at startup so schema changes land
# for every user on deploy, not only on their next request.
if _CLOUD_MODE and _CLOUD_DATA_ROOT.is_dir():
import logging as _log
_sweep_log = _log.getLogger("peregrine.startup")
for user_db in _CLOUD_DATA_ROOT.glob("*/peregrine/staging.db"):
try:
migrate_db(user_db)
_migrated_db_paths.add(str(user_db))
_sweep_log.info("Migrated user DB: %s", user_db)
except Exception as exc:
_sweep_log.warning("Migration failed for %s: %s", user_db, exc)
yield
@ -244,28 +230,6 @@ def _row_to_job(row) -> dict:
return d
def _shadow_score(date_posted: str | None, date_found: str | None) -> str | None:
"""Return 'shadow', 'stale', or None based on posting age when discovered.
A job posted >30 days before discovery is a shadow candidate (posted to
satisfy legal/HR requirements, likely already filled). 14-30 days is stale.
Returns None when dates are unavailable.
"""
if not date_posted or not date_found:
return None
try:
posted = datetime.fromisoformat(date_posted.replace("Z", "+00:00")).replace(tzinfo=timezone.utc)
found = datetime.fromisoformat(date_found.replace("Z", "+00:00")).replace(tzinfo=timezone.utc)
days = (found - posted).days
if days >= 30:
return "shadow"
if days >= 14:
return "stale"
except (ValueError, TypeError):
pass
return None
# ── GET /api/jobs ─────────────────────────────────────────────────────────────
@app.get("/api/jobs")
@ -273,7 +237,7 @@ def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""):
db = _get_db()
rows = db.execute(
"SELECT id, title, company, url, source, location, is_remote, salary, "
"description, match_score, keyword_gaps, date_found, date_posted, status, cover_letter "
"description, match_score, keyword_gaps, date_found, status, cover_letter "
"FROM jobs WHERE status = ? ORDER BY match_score DESC NULLS LAST LIMIT ?",
(status, limit),
).fetchall()
@ -282,7 +246,7 @@ def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""):
for r in rows:
d = _row_to_job(r)
d["has_cover_letter"] = bool(d.get("cover_letter"))
d["shadow_score"] = _shadow_score(d.get("date_posted"), d.get("date_found"))
# Don't send full cover_letter text in the list view
d.pop("cover_letter", None)
result.append(d)
return result
@ -773,17 +737,32 @@ async def import_resume_endpoint(file: UploadFile, name: str = ""):
text = content.decode("utf-8", errors="replace")
elif ext in (".pdf", ".docx", ".odt"):
from scripts.resume_parser import (
extract_text_from_pdf as _extract_pdf,
extract_text_from_docx as _extract_docx,
extract_text_from_odt as _extract_odt,
)
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
tmp.write(content)
tmp_path = tmp.name
try:
if ext == ".pdf":
text = _extract_pdf(content)
import pdfplumber
with pdfplumber.open(tmp_path) as pdf:
text = "\n".join(p.extract_text() or "" for p in pdf.pages)
elif ext == ".docx":
text = _extract_docx(content)
from docx import Document
doc = Document(tmp_path)
text = "\n".join(p.text for p in doc.paragraphs)
else:
text = _extract_odt(content)
import zipfile
from xml.etree import ElementTree as ET
with zipfile.ZipFile(tmp_path) as z:
xml = z.read("content.xml")
ET_root = ET.fromstring(xml)
text = "\n".join(
el.text or ""
for el in ET_root.iter(
"{urn:oasis:names:tc:opendocument:xmlns:text:1.0}p"
)
)
finally:
os.unlink(tmp_path)
elif ext in (".yaml", ".yml"):
import yaml as _yaml
@ -1511,284 +1490,6 @@ def suggest_qa_answer(job_id: int, payload: QASuggestPayload):
raise HTTPException(500, f"LLM generation failed: {e}")
# ── POST /api/jobs/:id/hired-feedback ─────────────────────────────────────────
class HiredFeedbackPayload(BaseModel):
what_helped: str = ""
factors: list[str] = []
@app.post("/api/jobs/{job_id}/hired-feedback")
def save_hired_feedback(job_id: int, payload: HiredFeedbackPayload):
db = _get_db()
row = db.execute("SELECT status FROM jobs WHERE id = ?", (job_id,)).fetchone()
if not row:
raise HTTPException(404, "Job not found")
if row["status"] != "hired":
raise HTTPException(400, "Feedback only accepted for hired jobs")
db.execute(
"UPDATE jobs SET hired_feedback = ? WHERE id = ?",
(json.dumps({"what_helped": payload.what_helped, "factors": payload.factors}), job_id),
)
db.commit()
db.close()
return {"ok": True}
# ── GET /api/contacts ──────────────────────────────────────────────────────────
@app.get("/api/contacts")
def list_contacts(job_id: Optional[int] = None, direction: Optional[str] = None,
search: Optional[str] = None, limit: int = 100, offset: int = 0):
db = _get_db()
query = """
SELECT jc.id, jc.job_id, jc.direction, jc.subject, jc.from_addr, jc.to_addr,
jc.received_at, jc.stage_signal,
j.title AS job_title, j.company AS job_company
FROM job_contacts jc
LEFT JOIN jobs j ON j.id = jc.job_id
WHERE 1=1
"""
params: list = []
if job_id is not None:
query += " AND jc.job_id = ?"
params.append(job_id)
if direction:
query += " AND jc.direction = ?"
params.append(direction)
if search:
query += " AND (jc.from_addr LIKE ? OR jc.to_addr LIKE ? OR jc.subject LIKE ?)"
like = f"%{search}%"
params += [like, like, like]
query += " ORDER BY jc.received_at DESC LIMIT ? OFFSET ?"
params += [limit, offset]
rows = db.execute(query, params).fetchall()
total = db.execute(
"SELECT COUNT(*) FROM job_contacts" + (" WHERE job_id = ?" if job_id else ""),
([job_id] if job_id else []),
).fetchone()[0]
db.close()
return {"total": total, "contacts": [dict(r) for r in rows]}
# ── References ─────────────────────────────────────────────────────────────────
class ReferencePayload(BaseModel):
name: str
relationship: str = ""
company: str = ""
email: str = ""
phone: str = ""
notes: str = ""
tags: list[str] = []
class PrepEmailPayload(BaseModel):
job_id: int
class RecLetterPayload(BaseModel):
job_id: int
talking_points: str = ""
@app.get("/api/references")
def list_references():
db = _get_db()
rows = db.execute(
"SELECT * FROM references_ ORDER BY name ASC"
).fetchall()
db.close()
return [dict(r) for r in rows]
@app.post("/api/references")
def create_reference(payload: ReferencePayload):
db = _get_db()
cur = db.execute(
"""INSERT INTO references_ (name, relationship, company, email, phone, notes, tags)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(payload.name, payload.relationship, payload.company,
payload.email, payload.phone, payload.notes,
json.dumps(payload.tags)),
)
db.commit()
row = db.execute("SELECT * FROM references_ WHERE id = ?", (cur.lastrowid,)).fetchone()
db.close()
return dict(row)
@app.patch("/api/references/{ref_id}")
def update_reference(ref_id: int, payload: ReferencePayload):
db = _get_db()
row = db.execute("SELECT id FROM references_ WHERE id = ?", (ref_id,)).fetchone()
if not row:
raise HTTPException(404, "Reference not found")
db.execute(
"""UPDATE references_ SET name=?, relationship=?, company=?, email=?, phone=?,
notes=?, tags=?, updated_at=datetime('now') WHERE id=?""",
(payload.name, payload.relationship, payload.company,
payload.email, payload.phone, payload.notes,
json.dumps(payload.tags), ref_id),
)
db.commit()
updated = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone()
db.close()
return dict(updated)
@app.delete("/api/references/{ref_id}")
def delete_reference(ref_id: int):
db = _get_db()
db.execute("DELETE FROM references_ WHERE id = ?", (ref_id,))
db.commit()
db.close()
return {"ok": True}
@app.get("/api/references/for-job/{job_id}")
def references_for_job(job_id: int):
db = _get_db()
rows = db.execute(
"""SELECT r.*, jr.prep_email, jr.rec_letter, jr.id AS jr_id
FROM references_ r
JOIN job_references jr ON jr.reference_id = r.id
WHERE jr.job_id = ?
ORDER BY r.name ASC""",
(job_id,),
).fetchall()
db.close()
return [dict(r) for r in rows]
@app.post("/api/references/{ref_id}/link-job")
def link_reference_to_job(ref_id: int, body: PrepEmailPayload):
db = _get_db()
try:
db.execute(
"INSERT INTO job_references (job_id, reference_id) VALUES (?, ?)",
(body.job_id, ref_id),
)
db.commit()
except Exception:
pass # already linked
db.close()
return {"ok": True}
@app.delete("/api/references/{ref_id}/unlink-job/{job_id}")
def unlink_reference_from_job(ref_id: int, job_id: int):
db = _get_db()
db.execute(
"DELETE FROM job_references WHERE reference_id = ? AND job_id = ?",
(ref_id, job_id),
)
db.commit()
db.close()
return {"ok": True}
@app.post("/api/references/{ref_id}/prep-email")
def generate_prep_email(ref_id: int, payload: PrepEmailPayload):
"""Draft a short 'heads up' email to send a reference before they hear from the hiring team."""
db = _get_db()
ref_row = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone()
if not ref_row:
db.close()
raise HTTPException(404, "Reference not found")
job_row = db.execute(
"SELECT title, company, description FROM jobs WHERE id = ?", (payload.job_id,)
).fetchone()
if not job_row:
db.close()
raise HTTPException(404, "Job not found")
ref = dict(ref_row)
job = dict(job_row)
db.close()
prompt = f"""Draft a short, warm email to send to a professional reference before a job interview.
Reference: {ref['name']} ({ref['relationship']} at {ref['company']})
Role applying for: {job['title']} at {job['company']}
Job description excerpt: {(job['description'] or '')[:500]}
The email should:
- Be 3-4 short paragraphs max
- Thank them for being a reference
- Briefly describe the role and why it's a good fit
- Mention 1-2 specific accomplishments they could speak to
- Give them a heads-up they may be contacted soon
- Be warm and professional, not overly formal
Return only the email body (no subject line)."""
try:
from scripts.llm_router import LLMRouter
router = LLMRouter()
email_text = router.complete(prompt)
except Exception as e:
raise HTTPException(500, f"LLM generation failed: {e}")
# Persist to job_references
db = _get_db()
db.execute(
"""INSERT INTO job_references (job_id, reference_id, prep_email)
VALUES (?, ?, ?)
ON CONFLICT(job_id, reference_id) DO UPDATE SET prep_email = excluded.prep_email""",
(payload.job_id, ref_id, email_text),
)
db.commit()
db.close()
return {"prep_email": email_text}
@app.post("/api/references/{ref_id}/rec-letter")
def generate_rec_letter(ref_id: int, payload: RecLetterPayload):
"""Draft a recommendation letter the reference can edit and send on their letterhead."""
db = _get_db()
ref_row = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone()
if not ref_row:
db.close()
raise HTTPException(404, "Reference not found")
job_row = db.execute(
"SELECT title, company, description FROM jobs WHERE id = ?", (payload.job_id,)
).fetchone()
if not job_row:
db.close()
raise HTTPException(404, "Job not found")
ref = dict(ref_row)
job = dict(job_row)
db.close()
prompt = f"""Draft a professional recommendation letter that {ref['name']} ({ref['relationship']}) could send on their letterhead for a candidate applying to {job['title']} at {job['company']}.
Key talking points to highlight: {payload.talking_points or 'general professional excellence, collaboration, initiative'}
The letter should:
- Be addressed generically (Dear Hiring Manager)
- Be 3-4 paragraphs
- Sound natural written from the recommender's voice, not the candidate's
- Highlight specific, credible observations a {ref['relationship']} would have
- Close with strong endorsement and contact offer
Return only the letter body."""
try:
from scripts.llm_router import LLMRouter
router = LLMRouter()
letter_text = router.complete(prompt)
except Exception as e:
raise HTTPException(500, f"LLM generation failed: {e}")
db = _get_db()
db.execute(
"""INSERT INTO job_references (job_id, reference_id, rec_letter)
VALUES (?, ?, ?)
ON CONFLICT(job_id, reference_id) DO UPDATE SET rec_letter = excluded.rec_letter""",
(payload.job_id, ref_id, letter_text),
)
db.commit()
db.close()
return {"rec_letter": letter_text}
# ── GET /api/interviews ────────────────────────────────────────────────────────
PIPELINE_STATUSES = {
@ -1808,7 +1509,7 @@ def list_interviews():
f"SELECT id, title, company, url, location, is_remote, salary, "
f"match_score, keyword_gaps, status, "
f"interview_date, rejection_stage, "
f"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, survey_at, hired_feedback "
f"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, survey_at "
f"FROM jobs WHERE status IN ({placeholders}) "
f"ORDER BY match_score DESC NULLS LAST",
list(PIPELINE_STATUSES),

View file

@ -1,6 +0,0 @@
-- 006_date_posted.sql
-- Add date_posted column for shadow listing detection (stale/shadow score feature).
-- New DBs already have this column from the CREATE TABLE statement in db.py;
-- this migration adds it to existing user DBs.
ALTER TABLE jobs ADD COLUMN date_posted TEXT;

View file

@ -130,32 +130,6 @@ CREATE TABLE IF NOT EXISTS digest_queue (
)
"""
CREATE_REFERENCES = """
CREATE TABLE IF NOT EXISTS references_ (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
relationship TEXT,
company TEXT,
email TEXT,
phone TEXT,
notes TEXT,
tags TEXT DEFAULT '[]',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
"""
CREATE_JOB_REFERENCES = """
CREATE TABLE IF NOT EXISTS job_references (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
reference_id INTEGER NOT NULL REFERENCES references_(id) ON DELETE CASCADE,
prep_email TEXT,
rec_letter TEXT,
UNIQUE(job_id, reference_id)
);
"""
_MIGRATIONS = [
("cover_letter", "TEXT"),
("applied_at", "TEXT"),
@ -169,8 +143,6 @@ _MIGRATIONS = [
("calendar_event_id", "TEXT"),
("optimized_resume", "TEXT"), # ATS-rewritten resume text (paid tier)
("ats_gap_report", "TEXT"), # JSON gap report (free tier)
("date_posted", "TEXT"), # Original posting date from job board (shadow listing detection)
("hired_feedback", "TEXT"), # JSON: optional post-hire "what helped" response
]
@ -204,9 +176,6 @@ def _migrate_db(db_path: Path) -> None:
conn.execute("ALTER TABLE background_tasks ADD COLUMN params TEXT")
except sqlite3.OperationalError:
pass # column already exists
# Ensure references tables exist (CREATE IF NOT EXISTS is idempotent)
conn.execute(CREATE_REFERENCES)
conn.execute(CREATE_JOB_REFERENCES)
conn.commit()
conn.close()
@ -220,8 +189,6 @@ def init_db(db_path: Path = DEFAULT_DB) -> None:
conn.execute(CREATE_BACKGROUND_TASKS)
conn.execute(CREATE_SURVEY_RESPONSES)
conn.execute(CREATE_DIGEST_QUEUE)
conn.execute(CREATE_REFERENCES)
conn.execute(CREATE_JOB_REFERENCES)
conn.commit()
conn.close()
_migrate_db(db_path)
@ -235,8 +202,8 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
try:
cursor = conn.execute(
"""INSERT INTO jobs
(title, company, url, source, location, is_remote, salary, description, date_found, date_posted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(title, company, url, source, location, is_remote, salary, description, date_found)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
job.get("title", ""),
job.get("company", ""),
@ -247,7 +214,6 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
job.get("salary", ""),
job.get("description", ""),
job.get("date_found", ""),
job.get("date_posted", "") or "",
),
)
conn.commit()

View file

@ -307,10 +307,6 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_
elif job_dict.get("salary_source") and str(job_dict["salary_source"]) not in ("nan", "None", ""):
salary_str = str(job_dict["salary_source"])
_dp = job_dict.get("date_posted")
date_posted_str = (
_dp.isoformat() if hasattr(_dp, "isoformat") else str(_dp)
) if _dp and str(_dp) not in ("nan", "None", "") else ""
row = {
"url": url,
"title": _s(job_dict.get("title")),
@ -320,7 +316,6 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_
"is_remote": bool(job_dict.get("is_remote", False)),
"salary": salary_str,
"description": _s(job_dict.get("description")),
"date_posted": date_posted_str,
"_exclude_kw": exclude_kw,
}
if _insert_if_new(row, _s(job_dict.get("site"))):

View file

@ -96,8 +96,6 @@ import {
NewspaperIcon,
Cog6ToothIcon,
DocumentTextIcon,
UsersIcon,
IdentificationIcon,
} from '@heroicons/vue/24/outline'
import { useDigestStore } from '../stores/digest'
@ -157,8 +155,6 @@ const navLinks = computed(() => [
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
{ to: '/resumes', icon: DocumentTextIcon, label: 'Resumes' },
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
{ to: '/contacts', icon: UsersIcon, label: 'Contacts' },
{ to: '/references', icon: IdentificationIcon, label: 'References' },
{ to: '/digest', icon: NewspaperIcon, label: 'Digest',
badge: digestStore.entries.length || undefined },
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },

View file

@ -191,37 +191,6 @@ const columnColor = computed(() => {
}
return map[props.job.status] ?? 'var(--color-border)'
})
// Hired feedback
const FEEDBACK_FACTORS = [
'Resume match',
'Cover letter',
'Interview prep',
'Company research',
'Network / referral',
'Salary negotiation',
] as const
const feedbackDismissed = ref(false)
const feedbackSaved = ref(!!props.job.hired_feedback)
const feedbackText = ref('')
const feedbackFactors = ref<string[]>([])
const feedbackSaving = ref(false)
const showFeedbackWidget = computed(() =>
props.job.status === 'hired' && !feedbackDismissed.value && !feedbackSaved.value
)
async function saveFeedback() {
feedbackSaving.value = true
const { error } = await useApiFetch(`/api/jobs/${props.job.id}/hired-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ what_helped: feedbackText.value, factors: feedbackFactors.value }),
})
feedbackSaving.value = false
if (!error) feedbackSaved.value = true
}
</script>
<template>
@ -338,38 +307,6 @@ async function saveFeedback() {
@click.stop="sigExpanded = !sigExpanded"
>{{ sigExpanded ? ' less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
</template>
<!-- Hired feedback widget -->
<div v-if="showFeedbackWidget" class="hired-feedback" @click.stop>
<div class="hired-feedback__header">
<span class="hired-feedback__title">What helped you land this role?</span>
<button class="hired-feedback__dismiss" @click="feedbackDismissed = true" aria-label="Dismiss feedback"></button>
</div>
<div class="hired-feedback__factors">
<label
v-for="factor in FEEDBACK_FACTORS"
:key="factor"
class="hired-feedback__factor"
>
<input type="checkbox" :value="factor" v-model="feedbackFactors" />
{{ factor }}
</label>
</div>
<textarea
v-model="feedbackText"
class="hired-feedback__textarea"
placeholder="Anything else that made the difference…"
rows="2"
/>
<button
class="hired-feedback__save"
:disabled="feedbackSaving"
@click="saveFeedback"
>{{ feedbackSaving ? 'Saving…' : 'Save reflection' }}</button>
</div>
<div v-else-if="job.status === 'hired' && feedbackSaved" class="hired-feedback hired-feedback--saved">
Reflection saved.
</div>
</article>
</template>
@ -596,80 +533,4 @@ async function saveFeedback() {
background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer;
padding: 4px 12px; text-align: left;
}
/* ── Hired feedback widget ── */
.hired-feedback {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-border);
background: rgba(39, 174, 96, 0.04);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.hired-feedback--saved {
font-size: var(--text-sm);
color: var(--color-text-muted);
text-align: center;
padding: var(--space-2) var(--space-4);
}
.hired-feedback__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.hired-feedback__title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-success);
}
.hired-feedback__dismiss {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: var(--text-sm);
padding: 2px 4px;
}
.hired-feedback__factors {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.hired-feedback__factor {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
color: var(--color-text-muted);
cursor: pointer;
}
.hired-feedback__textarea {
width: 100%;
font-size: var(--text-sm);
padding: var(--space-2);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text);
resize: vertical;
box-sizing: border-box;
}
.hired-feedback__textarea:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.hired-feedback__save {
align-self: flex-end;
padding: var(--space-1) var(--space-3);
font-size: var(--text-sm);
background: var(--color-success);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
.hired-feedback__save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View file

@ -7,7 +7,7 @@
}"
:aria-label="`${job.title} at ${job.company}`"
>
<!-- Score badge + remote badge + shadow badge -->
<!-- Score badge + remote badge -->
<div class="job-card__badges">
<span
v-if="job.match_score !== null"
@ -18,18 +18,6 @@
{{ job.match_score }}%
</span>
<span v-if="job.is_remote" class="remote-badge">Remote</span>
<span
v-if="job.shadow_score === 'shadow'"
class="shadow-badge shadow-badge--shadow"
:title="`Posted 30+ days before discovery — may already be filled`"
aria-label="Possible shadow listing: posted long before discovery"
>Ghost post</span>
<span
v-else-if="job.shadow_score === 'stale'"
class="shadow-badge shadow-badge--stale"
:title="`Posted 14+ days before discovery — listing may be stale`"
aria-label="Stale listing: posted over 2 weeks before discovery"
>Stale</span>
</div>
<!-- Title + company -->
@ -190,28 +178,6 @@ const formattedDate = computed(() => {
color: var(--app-primary);
}
.shadow-badge {
display: inline-flex;
align-items: center;
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
cursor: help;
}
.shadow-badge--shadow {
background: rgba(99, 99, 99, 0.15);
color: var(--color-text-muted);
border: 1px solid rgba(99, 99, 99, 0.3);
}
.shadow-badge--stale {
background: rgba(212, 137, 26, 0.12);
color: var(--score-mid);
border: 1px solid rgba(212, 137, 26, 0.25);
}
.job-card__title {
font-family: var(--font-display);
font-size: var(--text-xl);

View file

@ -12,8 +12,6 @@ export const router = createRouter({
{ path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') },
{ path: '/resumes', component: () => import('../views/ResumesView.vue') },
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') },
{ path: '/contacts', component: () => import('../views/ContactsView.vue') },
{ path: '/references', component: () => import('../views/ReferencesView.vue') },
{ path: '/digest', component: () => import('../views/DigestView.vue') },
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },

View file

@ -30,7 +30,6 @@ export interface PipelineJob {
offer_at: string | null
hired_at: string | null
survey_at: string | null
hired_feedback: string | null // JSON: { what_helped, factors }
stage_signals: StageSignal[] // undismissed signals, newest first
}

View file

@ -15,8 +15,6 @@ export interface Job {
match_score: number | null
keyword_gaps: string | null // JSON-encoded string[]
date_found: string
date_posted: string | null
shadow_score: 'shadow' | 'stale' | null
status: string
}

View file

@ -1,333 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
interface Contact {
id: number
job_id: number
direction: 'inbound' | 'outbound'
subject: string | null
from_addr: string | null
to_addr: string | null
received_at: string | null
stage_signal: string | null
job_title: string | null
job_company: string | null
}
const contacts = ref<Contact[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const search = ref('')
const direction = ref<'all' | 'inbound' | 'outbound'>('all')
const searchInput = ref('')
let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function fetchContacts() {
loading.value = true
error.value = null
const params = new URLSearchParams({ limit: '100' })
if (direction.value !== 'all') params.set('direction', direction.value)
if (search.value) params.set('search', search.value)
const { data, error: fetchErr } = await useApiFetch<{ total: number; contacts: Contact[] }>(
`/api/contacts?${params}`
)
loading.value = false
if (fetchErr || !data) {
error.value = 'Failed to load contacts.'
return
}
contacts.value = data.contacts
total.value = data.total
}
function onSearchInput() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
search.value = searchInput.value
fetchContacts()
}, 300)
}
function onDirectionChange() {
fetchContacts()
}
function formatDate(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
}
function displayAddr(contact: Contact): string {
return contact.direction === 'inbound'
? contact.from_addr ?? '—'
: contact.to_addr ?? '—'
}
const signalLabel: Record<string, string> = {
interview_scheduled: '📅 Interview',
offer_received: '🟢 Offer',
rejected: '✖ Rejected',
positive_response: '✅ Positive',
survey_received: '📋 Survey',
}
onMounted(fetchContacts)
</script>
<template>
<div class="contacts-view">
<header class="contacts-header">
<h1 class="contacts-title">Contacts</h1>
<span class="contacts-count" v-if="total > 0">{{ total }} total</span>
</header>
<div class="contacts-toolbar">
<input
v-model="searchInput"
class="contacts-search"
type="search"
placeholder="Search name, email, or subject…"
aria-label="Search contacts"
@input="onSearchInput"
/>
<div class="contacts-filter" role="group" aria-label="Filter by direction">
<button
v-for="opt in (['all', 'inbound', 'outbound'] as const)"
:key="opt"
class="filter-btn"
:class="{ 'filter-btn--active': direction === opt }"
@click="direction = opt; onDirectionChange()"
>{{ opt === 'all' ? 'All' : opt === 'inbound' ? 'Inbound' : 'Outbound' }}</button>
</div>
</div>
<div v-if="loading" class="contacts-empty">Loading</div>
<div v-else-if="error" class="contacts-empty contacts-empty--error">{{ error }}</div>
<div v-else-if="contacts.length === 0" class="contacts-empty">
No contacts found{{ search ? ' for that search' : '' }}.
</div>
<div v-else class="contacts-table-wrap">
<table class="contacts-table" aria-label="Contacts">
<thead>
<tr>
<th>Contact</th>
<th>Subject</th>
<th>Job</th>
<th>Signal</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr
v-for="c in contacts"
:key="c.id"
class="contacts-row"
:class="{ 'contacts-row--inbound': c.direction === 'inbound' }"
>
<td class="contacts-cell contacts-cell--addr">
<span class="dir-chip" :class="`dir-chip--${c.direction}`">
{{ c.direction === 'inbound' ? '↓' : '↑' }}
</span>
{{ displayAddr(c) }}
</td>
<td class="contacts-cell contacts-cell--subject">
{{ c.subject ? c.subject.slice(0, 60) + (c.subject.length > 60 ? '…' : '') : '—' }}
</td>
<td class="contacts-cell contacts-cell--job">
<span v-if="c.job_title">
{{ c.job_title }}<span v-if="c.job_company" class="job-company"> · {{ c.job_company }}</span>
</span>
<span v-else class="text-muted"></span>
</td>
<td class="contacts-cell contacts-cell--signal">
<span v-if="c.stage_signal && signalLabel[c.stage_signal]" class="signal-chip">
{{ signalLabel[c.stage_signal] }}
</span>
</td>
<td class="contacts-cell contacts-cell--date">{{ formatDate(c.received_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.contacts-view {
padding: var(--space-6);
max-width: 1000px;
}
.contacts-header {
display: flex;
align-items: baseline;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.contacts-title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.contacts-count {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.contacts-toolbar {
display: flex;
gap: var(--space-3);
align-items: center;
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.contacts-search {
flex: 1;
min-width: 200px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
}
.contacts-search:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.contacts-filter {
display: flex;
gap: 4px;
}
.filter-btn {
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text-muted);
font-size: var(--text-sm);
cursor: pointer;
}
.filter-btn--active {
background: var(--app-primary-light);
color: var(--app-primary);
border-color: var(--app-primary);
font-weight: 600;
}
.contacts-empty {
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-8) 0;
text-align: center;
}
.contacts-empty--error {
color: var(--color-error, #c0392b);
}
.contacts-table-wrap {
overflow-x: auto;
}
.contacts-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.contacts-table th {
text-align: left;
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--color-border);
}
.contacts-row {
border-bottom: 1px solid var(--color-border);
}
.contacts-row:hover {
background: var(--color-hover);
}
.contacts-cell {
padding: var(--space-3);
vertical-align: top;
color: var(--color-text);
}
.contacts-cell--addr {
white-space: nowrap;
font-size: var(--text-xs);
font-family: var(--font-mono);
display: flex;
align-items: center;
gap: var(--space-2);
}
.contacts-cell--subject {
color: var(--color-text-muted);
}
.contacts-cell--job {
font-size: var(--text-xs);
}
.job-company {
color: var(--color-text-muted);
}
.contacts-cell--date {
white-space: nowrap;
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.dir-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
}
.dir-chip--inbound {
background: rgba(39, 174, 96, 0.15);
color: var(--color-success);
}
.dir-chip--outbound {
background: var(--app-primary-light);
color: var(--app-primary);
}
.signal-chip {
font-size: var(--text-xs);
white-space: nowrap;
}
.text-muted {
color: var(--color-text-muted);
}
</style>

View file

@ -1,469 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
export interface Reference {
id: number
name: string
relationship: string
company: string
email: string
phone: string
notes: string
tags: string // JSON-encoded string[]
created_at: string
updated_at: string
}
const TAG_OPTIONS = ['technical', 'managerial', 'character', 'academic'] as const
const references = ref<Reference[]>([])
const loading = ref(false)
const saving = ref(false)
const editingId = ref<number | 'new' | null>(null)
const deleteConfirmId = ref<number | null>(null)
const blankForm = () => ({
name: '', relationship: '', company: '', email: '', phone: '', notes: '', tags: [] as string[],
})
const form = ref(blankForm())
async function fetchRefs() {
loading.value = true
const { data } = await useApiFetch<Reference[]>('/api/references')
loading.value = false
if (data) references.value = data
}
function parseTags(raw: string): string[] {
try { return JSON.parse(raw) } catch { return [] }
}
function startNew() {
form.value = blankForm()
editingId.value = 'new'
}
function startEdit(ref: Reference) {
form.value = {
name: ref.name,
relationship: ref.relationship,
company: ref.company,
email: ref.email,
phone: ref.phone,
notes: ref.notes,
tags: parseTags(ref.tags),
}
editingId.value = ref.id
}
function cancelEdit() {
editingId.value = null
form.value = blankForm()
}
async function saveRef() {
if (!form.value.name.trim()) return
saving.value = true
const isNew = editingId.value === 'new'
const url = isNew ? '/api/references' : `/api/references/${editingId.value}`
const { data, error } = await useApiFetch<Reference>(url, {
method: isNew ? 'POST' : 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
})
saving.value = false
if (error || !data) return
if (isNew) {
references.value = [...references.value, data]
} else {
references.value = references.value.map(r => r.id === data.id ? data : r)
}
cancelEdit()
}
async function deleteRef(id: number) {
await useApiFetch(`/api/references/${id}`, { method: 'DELETE' })
references.value = references.value.filter(r => r.id !== id)
deleteConfirmId.value = null
}
function tagLabel(tag: string): string {
const map: Record<string, string> = {
technical: 'Technical', managerial: 'Manager', character: 'Character', academic: 'Academic',
}
return map[tag] ?? tag
}
onMounted(fetchRefs)
</script>
<template>
<div class="refs-view">
<header class="refs-header">
<div class="refs-header__left">
<h1 class="refs-title">References</h1>
<span v-if="references.length" class="refs-count">{{ references.length }}</span>
</div>
<button class="btn-add" @click="startNew" :disabled="editingId !== null">
+ Add reference
</button>
</header>
<p class="refs-hint">
Store your references once, associate them with applications, and generate
a heads-up email or draft recommendation letter with one click.
</p>
<!-- New / edit form -->
<div v-if="editingId !== null" class="ref-form">
<h2 class="ref-form__title">{{ editingId === 'new' ? 'Add reference' : 'Edit reference' }}</h2>
<div class="ref-form__grid">
<label class="ref-form__field ref-form__field--full">
<span>Name <span class="required">*</span></span>
<input v-model="form.name" class="ref-input" placeholder="Full name" />
</label>
<label class="ref-form__field">
<span>Relationship</span>
<input v-model="form.relationship" class="ref-input" placeholder="e.g. Former manager" />
</label>
<label class="ref-form__field">
<span>Company</span>
<input v-model="form.company" class="ref-input" placeholder="Where you worked together" />
</label>
<label class="ref-form__field">
<span>Email</span>
<input v-model="form.email" class="ref-input" type="email" placeholder="email@example.com" />
</label>
<label class="ref-form__field">
<span>Phone</span>
<input v-model="form.phone" class="ref-input" type="tel" placeholder="+1 555 000 0000" />
</label>
<label class="ref-form__field ref-form__field--full">
<span>Notes</span>
<textarea v-model="form.notes" class="ref-input ref-textarea"
placeholder="Accomplishments they can speak to, context, anything to remind yourself…"
rows="2" />
</label>
<div class="ref-form__field ref-form__field--full">
<span>Type</span>
<div class="ref-tags">
<label v-for="t in TAG_OPTIONS" :key="t" class="ref-tag-option">
<input type="checkbox" :value="t" v-model="form.tags" />
{{ tagLabel(t) }}
</label>
</div>
</div>
</div>
<div class="ref-form__actions">
<button class="btn-save" @click="saveRef" :disabled="saving || !form.name.trim()">
{{ saving ? 'Saving…' : editingId === 'new' ? 'Add' : 'Save' }}
</button>
<button class="btn-cancel" @click="cancelEdit">Cancel</button>
</div>
</div>
<!-- Empty state -->
<div v-if="!loading && references.length === 0 && editingId === null" class="refs-empty">
No references yet. Add someone who can speak to your work.
</div>
<!-- Reference cards -->
<ul v-else class="refs-list" aria-label="References">
<li
v-for="ref in references"
:key="ref.id"
class="ref-card"
:class="{ 'ref-card--editing': editingId === ref.id }"
>
<div class="ref-card__body">
<div class="ref-card__name">{{ ref.name }}</div>
<div class="ref-card__meta">
<span v-if="ref.relationship">{{ ref.relationship }}</span>
<span v-if="ref.relationship && ref.company" class="sep"> · </span>
<span v-if="ref.company">{{ ref.company }}</span>
</div>
<div class="ref-card__contact">
<a v-if="ref.email" :href="`mailto:${ref.email}`" class="ref-link">{{ ref.email }}</a>
<span v-if="ref.email && ref.phone" class="sep"> · </span>
<span v-if="ref.phone">{{ ref.phone }}</span>
</div>
<div v-if="ref.notes" class="ref-card__notes">{{ ref.notes }}</div>
<div v-if="parseTags(ref.tags).length" class="ref-card__tags">
<span v-for="t in parseTags(ref.tags)" :key="t" class="tag-chip" :class="`tag-chip--${t}`">
{{ tagLabel(t) }}
</span>
</div>
</div>
<div class="ref-card__actions">
<button class="btn-ghost" @click="startEdit(ref)" :disabled="editingId !== null">Edit</button>
<button
v-if="deleteConfirmId !== ref.id"
class="btn-ghost btn-ghost--danger"
@click="deleteConfirmId = ref.id"
:disabled="editingId !== null"
>Delete</button>
<template v-else>
<button class="btn-ghost btn-ghost--danger" @click="deleteRef(ref.id)">Confirm</button>
<button class="btn-ghost" @click="deleteConfirmId = null">Cancel</button>
</template>
</div>
</li>
</ul>
</div>
</template>
<style scoped>
.refs-view {
padding: var(--space-6);
max-width: 760px;
}
.refs-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-2);
}
.refs-header__left {
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.refs-title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.refs-count {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.refs-hint {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin: 0 0 var(--space-5);
line-height: 1.5;
}
.btn-add {
padding: var(--space-2) var(--space-4);
background: var(--app-primary);
color: #fff;
border: none;
border-radius: 8px;
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
}
.btn-add:disabled { opacity: 0.5; cursor: not-allowed; }
/* Form */
.ref-form {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: var(--space-5);
margin-bottom: var(--space-5);
}
.ref-form__title {
font-size: var(--text-lg);
font-weight: 600;
margin: 0 0 var(--space-4);
}
.ref-form__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.ref-form__field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.ref-form__field--full { grid-column: 1 / -1; }
.required { color: var(--color-error, #c0392b); }
.ref-input {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
}
.ref-input:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.ref-textarea { resize: vertical; font-family: inherit; }
.ref-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin-top: 4px;
}
.ref-tag-option {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-sm);
cursor: pointer;
}
.ref-form__actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-4);
}
.btn-save {
padding: var(--space-2) var(--space-4);
background: var(--app-primary);
color: #fff;
border: none;
border-radius: 6px;
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
}
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-cancel {
padding: var(--space-2) var(--space-4);
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: var(--text-sm);
cursor: pointer;
color: var(--color-text-muted);
}
/* Empty */
.refs-empty {
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-8) 0;
text-align: center;
}
/* List */
.refs-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.ref-card {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: var(--space-4);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
}
.ref-card--editing {
border-color: var(--app-primary);
box-shadow: 0 0 0 2px var(--app-primary-light);
}
.ref-card__body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.ref-card__name {
font-weight: 600;
font-size: var(--text-base);
color: var(--color-text);
}
.ref-card__meta, .ref-card__contact {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.ref-card__notes {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-top: 4px;
font-style: italic;
}
.ref-link {
color: var(--app-primary);
text-decoration: none;
}
.ref-link:hover { text-decoration: underline; }
.sep { margin: 0 2px; }
.ref-card__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: var(--space-2);
}
.tag-chip {
padding: 2px 8px;
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
}
.tag-chip--technical { background: var(--app-primary-light); color: var(--app-primary); }
.tag-chip--managerial { background: rgba(39, 174, 96, 0.12); color: var(--color-success); }
.tag-chip--character { background: rgba(212, 137, 26, 0.12); color: var(--score-mid); }
.tag-chip--academic { background: rgba(103, 58, 183, 0.12); color: #7c3aed; }
.ref-card__actions {
display: flex;
flex-direction: column;
gap: var(--space-1);
flex-shrink: 0;
}
.btn-ghost {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 3px var(--space-3);
font-size: var(--text-xs);
cursor: pointer;
color: var(--color-text-muted);
white-space: nowrap;
}
.btn-ghost:hover { background: var(--color-hover); }
.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-ghost--danger { color: var(--color-error, #c0392b); border-color: rgba(192, 57, 43, 0.3); }
.btn-ghost--danger:hover { background: rgba(192, 57, 43, 0.07); }
</style>