42 KiB
Digest Scrape Queue Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a persistent digest queue so users can click the 📰 Digest chip on a signal banner, browse extracted job links from queued digest emails, and send selected URLs through the existing discovery pipeline as pending jobs.
Architecture: New digest_queue table in staging.db stores queued digest emails (foreign-keyed to job_contacts). Four new FastAPI endpoints in dev-api.py handle list/add/extract/queue-jobs/delete. A new Pinia store + DigestView.vue page provide the browse UI. The existing reclassify chip handler gets a third fire-and-forget API call for digest entries.
Tech Stack: Python / FastAPI / SQLite / Vue 3 / TypeScript / Pinia / Heroicons Vue
File Map
| File | What changes |
|---|---|
scripts/db.py |
Add CREATE_DIGEST_QUEUE string; call it in init_db() |
dev-api.py |
@app.on_event("startup") for digest table; _score_url() + extract_links() helpers; 4 new endpoints; DigestQueueBody + QueueJobsBody Pydantic models |
tests/test_dev_api_digest.py |
New file — 11 tests with isolated tmp_db fixture |
web/src/stores/digest.ts |
New Pinia store — DigestEntry, DigestLink types; fetchAll(), remove() actions |
web/src/views/DigestView.vue |
New page — entry list, expand/extract/select/queue UI |
web/src/router/index.ts |
Add /digest route |
web/src/components/AppNav.vue |
Add NewspaperIcon import; add Digest nav item; import digest store; reactive badge in template |
web/src/components/InterviewCard.vue |
Third fire-and-forget call in reclassifySignal digest branch |
web/src/views/InterviewsView.vue |
Third fire-and-forget call in reclassifyPreSignal digest branch |
Task 1: DB schema — digest_queue table
Files:
-
Modify:
scripts/db.py:138,188-197 -
Modify:
dev-api.py(add startup event after_strip_htmldefinition) -
Step 1: Add
CREATE_DIGEST_QUEUEstring toscripts/db.py
After the CREATE_SURVEY_RESPONSES string (around line 138), insert:
CREATE_DIGEST_QUEUE = """
CREATE TABLE IF NOT EXISTS digest_queue (
id INTEGER PRIMARY KEY,
job_contact_id INTEGER NOT NULL REFERENCES job_contacts(id),
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(job_contact_id)
)
"""
- Step 2: Call it in
init_db()
In init_db() (around line 195), add after the existing CREATE_SURVEY_RESPONSES call:
conn.execute(CREATE_DIGEST_QUEUE)
The full init_db body should now be:
def init_db(db_path: Path = DEFAULT_DB) -> None:
"""Create tables if they don't exist, then run migrations."""
conn = sqlite3.connect(db_path)
conn.execute(CREATE_JOBS)
conn.execute(CREATE_JOB_CONTACTS)
conn.execute(CREATE_COMPANY_RESEARCH)
conn.execute(CREATE_BACKGROUND_TASKS)
conn.execute(CREATE_SURVEY_RESPONSES)
conn.execute(CREATE_DIGEST_QUEUE)
conn.commit()
conn.close()
_migrate_db(db_path)
- Step 3: Add startup event to
dev-api.py
After the _strip_html function definition, add:
@app.on_event("startup")
def _startup():
"""Ensure digest_queue table exists (dev-api may run against an existing DB)."""
db = _get_db()
db.execute("""
CREATE TABLE IF NOT EXISTS digest_queue (
id INTEGER PRIMARY KEY,
job_contact_id INTEGER NOT NULL REFERENCES job_contacts(id),
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(job_contact_id)
)
""")
db.commit()
db.close()
- Step 4: Verify schema creation
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
conda run -n job-seeker python -c "
from scripts.db import init_db
import tempfile, os
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, 'staging.db')
init_db(p)
import sqlite3
con = sqlite3.connect(p)
tables = con.execute(\"SELECT name FROM sqlite_master WHERE type='table'\").fetchall()
print([t[0] for t in tables])
"
Expected: list includes digest_queue
- Step 5: Commit
git add scripts/db.py dev-api.py
git commit -m "feat: add digest_queue table to schema and dev-api startup"
Task 2: GET + POST /api/digest-queue endpoints + tests
Files:
-
Create:
tests/test_dev_api_digest.py -
Modify:
dev-api.py(append after reclassify endpoint) -
Step 1: Create test file with fixture + GET + POST tests
Create /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/tests/test_dev_api_digest.py:
"""Tests for digest queue API endpoints."""
import sqlite3
import os
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def tmp_db(tmp_path):
"""Create minimal schema in a temp dir with one job_contacts row."""
db_path = str(tmp_path / "staging.db")
con = sqlite3.connect(db_path)
con.executescript("""
CREATE TABLE jobs (
id INTEGER PRIMARY KEY,
title TEXT, company TEXT, url TEXT UNIQUE, location TEXT,
is_remote INTEGER DEFAULT 0, salary TEXT,
match_score REAL, keyword_gaps TEXT, status TEXT DEFAULT 'pending',
date_found TEXT, description TEXT, source TEXT
);
CREATE TABLE job_contacts (
id INTEGER PRIMARY KEY,
job_id INTEGER,
subject TEXT,
received_at TEXT,
stage_signal TEXT,
suggestion_dismissed INTEGER DEFAULT 0,
body TEXT,
from_addr TEXT
);
CREATE TABLE digest_queue (
id INTEGER PRIMARY KEY,
job_contact_id INTEGER NOT NULL REFERENCES job_contacts(id),
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(job_contact_id)
);
INSERT INTO jobs (id, title, company, url, status, source, date_found)
VALUES (1, 'Engineer', 'Acme', 'https://acme.com/job/1', 'applied', 'test', '2026-03-19');
INSERT INTO job_contacts (id, job_id, subject, received_at, stage_signal, body, from_addr)
VALUES (
10, 1, 'TechCrunch Jobs Weekly', '2026-03-19T10:00:00', 'digest',
'<html><body>Apply at <a href="https://greenhouse.io/acme/jobs/456">Senior Engineer</a> or <a href="https://lever.co/globex/staff">Staff Designer</a>. Unsubscribe: https://unsubscribe.example.com/remove</body></html>',
'digest@techcrunch.com'
);
""")
con.close()
return db_path
@pytest.fixture()
def client(tmp_db, monkeypatch):
monkeypatch.setenv("STAGING_DB", tmp_db)
import importlib
import dev_api
importlib.reload(dev_api)
return TestClient(dev_api.app)
# ── GET /api/digest-queue ───────────────────────────────────────────────────
def test_digest_queue_list_empty(client):
resp = client.get("/api/digest-queue")
assert resp.status_code == 200
assert resp.json() == []
def test_digest_queue_list_with_entry(client, tmp_db):
con = sqlite3.connect(tmp_db)
con.execute("INSERT INTO digest_queue (job_contact_id) VALUES (10)")
con.commit()
con.close()
resp = client.get("/api/digest-queue")
assert resp.status_code == 200
entries = resp.json()
assert len(entries) == 1
assert entries[0]["job_contact_id"] == 10
assert entries[0]["subject"] == "TechCrunch Jobs Weekly"
assert entries[0]["from_addr"] == "digest@techcrunch.com"
assert "body" in entries[0]
assert "created_at" in entries[0]
# ── POST /api/digest-queue ──────────────────────────────────────────────────
def test_digest_queue_add(client, tmp_db):
resp = client.post("/api/digest-queue", json={"job_contact_id": 10})
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["created"] is True
con = sqlite3.connect(tmp_db)
row = con.execute("SELECT * FROM digest_queue WHERE job_contact_id = 10").fetchone()
con.close()
assert row is not None
def test_digest_queue_add_duplicate(client):
client.post("/api/digest-queue", json={"job_contact_id": 10})
resp = client.post("/api/digest-queue", json={"job_contact_id": 10})
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["created"] is False
def test_digest_queue_add_missing_contact(client):
resp = client.post("/api/digest-queue", json={"job_contact_id": 9999})
assert resp.status_code == 404
- Step 2: Run tests — expect failures
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_digest.py -v 2>&1 | tail -20
Expected: 4 FAILED, 1 PASSED. test_digest_queue_add_missing_contact passes immediately because a POST to a non-existent endpoint returns 404, matching the assertion — this is expected and fine.
- Step 3: Implement
GET /api/digest-queue+POST /api/digest-queueindev-api.py
After the reclassify endpoint (after line ~428), add:
# ── Digest queue models ───────────────────────────────────────────────────
class DigestQueueBody(BaseModel):
job_contact_id: int
# ── GET /api/digest-queue ─────────────────────────────────────────────────
@app.get("/api/digest-queue")
def list_digest_queue():
db = _get_db()
rows = db.execute(
"""SELECT dq.id, dq.job_contact_id, dq.created_at,
jc.subject, jc.from_addr, jc.received_at, jc.body
FROM digest_queue dq
JOIN job_contacts jc ON jc.id = dq.job_contact_id
ORDER BY dq.created_at DESC"""
).fetchall()
db.close()
return [
{
"id": r["id"],
"job_contact_id": r["job_contact_id"],
"created_at": r["created_at"],
"subject": r["subject"],
"from_addr": r["from_addr"],
"received_at": r["received_at"],
"body": _strip_html(r["body"]),
}
for r in rows
]
# ── POST /api/digest-queue ────────────────────────────────────────────────
@app.post("/api/digest-queue")
def add_to_digest_queue(body: DigestQueueBody):
db = _get_db()
exists = db.execute(
"SELECT 1 FROM job_contacts WHERE id = ?", (body.job_contact_id,)
).fetchone()
if not exists:
db.close()
raise HTTPException(404, "job_contact_id not found")
result = db.execute(
"INSERT OR IGNORE INTO digest_queue (job_contact_id) VALUES (?)",
(body.job_contact_id,),
)
db.commit()
created = result.rowcount > 0
db.close()
return {"ok": True, "created": created}
- Step 4: Run tests — expect all pass
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_digest.py -v 2>&1 | tail -15
Expected: 5 PASSED
- Step 5: Run full suite to catch regressions
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v 2>&1 | tail -5
Expected: all previously passing tests still pass
- Step 6: Commit
git add dev-api.py tests/test_dev_api_digest.py
git commit -m "feat: add GET/POST /api/digest-queue endpoints"
Task 3: POST /api/digest-queue/{id}/extract-links + tests
Files:
-
Modify:
tests/test_dev_api_digest.py(append tests) -
Modify:
dev-api.py(append helpers + endpoint) -
Step 1: Add extract-links tests to
test_dev_api_digest.py
Append to tests/test_dev_api_digest.py:
# ── POST /api/digest-queue/{id}/extract-links ───────────────────────────────
def _add_digest_entry(tmp_db, contact_id=10):
"""Helper: insert a digest_queue row and return its id."""
con = sqlite3.connect(tmp_db)
cur = con.execute("INSERT INTO digest_queue (job_contact_id) VALUES (?)", (contact_id,))
entry_id = cur.lastrowid
con.commit()
con.close()
return entry_id
def test_digest_extract_links(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.post(f"/api/digest-queue/{entry_id}/extract-links")
assert resp.status_code == 200
links = resp.json()["links"]
urls = [l["url"] for l in links]
# greenhouse.io link should be present with score=2
gh_links = [l for l in links if "greenhouse.io" in l["url"]]
assert len(gh_links) == 1
assert gh_links[0]["score"] == 2
# lever.co link should be present with score=2
lever_links = [l for l in links if "lever.co" in l["url"]]
assert len(lever_links) == 1
assert lever_links[0]["score"] == 2
def test_digest_extract_links_filters_trackers(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.post(f"/api/digest-queue/{entry_id}/extract-links")
assert resp.status_code == 200
links = resp.json()["links"]
urls = [l["url"] for l in links]
# Unsubscribe URL should be excluded
assert not any("unsubscribe" in u for u in urls)
def test_digest_extract_links_404(client):
resp = client.post("/api/digest-queue/9999/extract-links")
assert resp.status_code == 404
- Step 2: Run new tests — expect failures
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_digest.py::test_digest_extract_links tests/test_dev_api_digest.py::test_digest_extract_links_filters_trackers tests/test_dev_api_digest.py::test_digest_extract_links_404 -v 2>&1 | tail -10
Expected: 3 FAILED
- Step 3: Add
_score_url()andextract_links()todev-api.py
First, add urlparse to the import block at the top of dev-api.py (around line 10, with the other stdlib imports):
from urllib.parse import urlparse
Then, after the _startup event function (before the endpoint definitions), add the helpers:
# ── Link extraction helpers ───────────────────────────────────────────────
_JOB_DOMAINS = frozenset({
'greenhouse.io', 'lever.co', 'workday.com', 'linkedin.com',
'ashbyhq.com', 'smartrecruiters.com', 'icims.com', 'taleo.net',
'jobvite.com', 'breezy.hr', 'recruitee.com', 'bamboohr.com',
'myworkdayjobs.com',
})
_JOB_PATH_SEGMENTS = frozenset({'careers', 'jobs'})
_FILTER_RE = re.compile(
r'(unsubscribe|mailto:|/track/|pixel\.|\.gif|\.png|\.jpg'
r'|/open\?|/click\?|list-unsubscribe)',
re.I,
)
_URL_RE = re.compile(r'https?://[^\s<>"\')\]]+', re.I)
def _score_url(url: str) -> int:
"""Return 2 for likely job URLs, 1 for others, -1 to exclude."""
if _FILTER_RE.search(url):
return -1
parsed = urlparse(url)
hostname = (parsed.hostname or '').lower()
path = parsed.path.lower()
for domain in _JOB_DOMAINS:
if domain in hostname or domain in path:
return 2
for seg in _JOB_PATH_SEGMENTS:
if f'/{seg}/' in path or path.startswith(f'/{seg}'):
return 2
return 1
def _extract_links(body: str) -> list[dict]:
"""Extract and rank URLs from raw HTML email body."""
if not body:
return []
seen: set[str] = set()
results = []
for m in _URL_RE.finditer(body):
url = m.group(0).rstrip('.,;)')
if url in seen:
continue
seen.add(url)
score = _score_url(url)
if score < 0:
continue
start = max(0, m.start() - 60)
hint = body[start:m.start()].strip().split('\n')[-1].strip()
results.append({'url': url, 'score': score, 'hint': hint})
results.sort(key=lambda x: -x['score'])
return results
- Step 4: Add
POST /api/digest-queue/{id}/extract-linksendpoint
After add_to_digest_queue, add:
# ── POST /api/digest-queue/{id}/extract-links ─────────────────────────────
@app.post("/api/digest-queue/{digest_id}/extract-links")
def extract_digest_links(digest_id: int):
db = _get_db()
row = db.execute(
"""SELECT jc.body
FROM digest_queue dq
JOIN job_contacts jc ON jc.id = dq.job_contact_id
WHERE dq.id = ?""",
(digest_id,),
).fetchone()
db.close()
if not row:
raise HTTPException(404, "Digest entry not found")
return {"links": _extract_links(row["body"] or "")}
- Step 5: Run tests — expect all pass
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_digest.py -v 2>&1 | tail -15
Expected: 8 PASSED
- Step 6: Commit
git add dev-api.py tests/test_dev_api_digest.py
git commit -m "feat: add /extract-links endpoint with URL scoring"
Task 4: POST /api/digest-queue/{id}/queue-jobs + DELETE /api/digest-queue/{id} + tests
Files:
-
Modify:
tests/test_dev_api_digest.py(append tests) -
Modify:
dev-api.py(append models + endpoints) -
Step 1: Add queue-jobs and delete tests
Append to tests/test_dev_api_digest.py:
# ── POST /api/digest-queue/{id}/queue-jobs ──────────────────────────────────
def test_digest_queue_jobs(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.post(
f"/api/digest-queue/{entry_id}/queue-jobs",
json={"urls": ["https://greenhouse.io/acme/jobs/456"]},
)
assert resp.status_code == 200
data = resp.json()
assert data["queued"] == 1
assert data["skipped"] == 0
con = sqlite3.connect(tmp_db)
row = con.execute(
"SELECT source, status FROM jobs WHERE url = 'https://greenhouse.io/acme/jobs/456'"
).fetchone()
con.close()
assert row is not None
assert row[0] == "digest"
assert row[1] == "pending"
def test_digest_queue_jobs_skips_duplicates(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.post(
f"/api/digest-queue/{entry_id}/queue-jobs",
json={"urls": [
"https://greenhouse.io/acme/jobs/789",
"https://greenhouse.io/acme/jobs/789", # same URL twice in one call
]},
)
assert resp.status_code == 200
data = resp.json()
assert data["queued"] == 1
assert data["skipped"] == 1
con = sqlite3.connect(tmp_db)
count = con.execute(
"SELECT COUNT(*) FROM jobs WHERE url = 'https://greenhouse.io/acme/jobs/789'"
).fetchone()[0]
con.close()
assert count == 1
def test_digest_queue_jobs_skips_invalid_urls(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.post(
f"/api/digest-queue/{entry_id}/queue-jobs",
json={"urls": ["", "ftp://bad.example.com", "https://valid.greenhouse.io/job/1"]},
)
assert resp.status_code == 200
data = resp.json()
assert data["queued"] == 1
assert data["skipped"] == 2
def test_digest_queue_jobs_empty_urls(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.post(f"/api/digest-queue/{entry_id}/queue-jobs", json={"urls": []})
assert resp.status_code == 400
def test_digest_queue_jobs_404(client):
resp = client.post("/api/digest-queue/9999/queue-jobs", json={"urls": ["https://example.com"]})
assert resp.status_code == 404
# ── DELETE /api/digest-queue/{id} ───────────────────────────────────────────
def test_digest_delete(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.delete(f"/api/digest-queue/{entry_id}")
assert resp.status_code == 200
assert resp.json()["ok"] is True
# Second delete → 404
resp2 = client.delete(f"/api/digest-queue/{entry_id}")
assert resp2.status_code == 404
- Step 2: Run new tests — expect failures
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_digest.py -k "queue_jobs or delete" -v 2>&1 | tail -15
Expected: 6 FAILED
- Step 3: Add
QueueJobsBodymodel + both endpoints todev-api.py
class QueueJobsBody(BaseModel):
urls: list[str]
# ── POST /api/digest-queue/{id}/queue-jobs ────────────────────────────────
@app.post("/api/digest-queue/{digest_id}/queue-jobs")
def queue_digest_jobs(digest_id: int, body: QueueJobsBody):
if not body.urls:
raise HTTPException(400, "urls must not be empty")
db = _get_db()
exists = db.execute(
"SELECT 1 FROM digest_queue WHERE id = ?", (digest_id,)
).fetchone()
db.close()
if not exists:
raise HTTPException(404, "Digest entry not found")
try:
from scripts.db import insert_job
except ImportError:
raise HTTPException(500, "scripts.db not available")
queued = 0
skipped = 0
for url in body.urls:
if not url or not url.startswith(('http://', 'https://')):
skipped += 1
continue
result = insert_job(DB_PATH, {
'url': url,
'title': '',
'company': '',
'source': 'digest',
'date_found': datetime.utcnow().isoformat(),
})
if result:
queued += 1
else:
skipped += 1
return {"ok": True, "queued": queued, "skipped": skipped}
# ── DELETE /api/digest-queue/{id} ────────────────────────────────────────
@app.delete("/api/digest-queue/{digest_id}")
def delete_digest_entry(digest_id: int):
db = _get_db()
result = db.execute("DELETE FROM digest_queue WHERE id = ?", (digest_id,))
db.commit()
rowcount = result.rowcount
db.close()
if rowcount == 0:
raise HTTPException(404, "Digest entry not found")
return {"ok": True}
- Step 4: Run full digest test suite — expect all pass
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_digest.py -v 2>&1 | tail -20
Expected: 14 PASSED
- Step 5: Run full test suite
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v 2>&1 | tail -5
Expected: all previously passing tests still pass
- Step 6: Commit
git add dev-api.py tests/test_dev_api_digest.py
git commit -m "feat: add queue-jobs and delete digest endpoints"
Task 5: Pinia store — web/src/stores/digest.ts
Files:
-
Create:
web/src/stores/digest.ts -
Step 1: Create the store
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApiFetch } from '@/composables/useApi'
export interface DigestEntry {
id: number
job_contact_id: number
created_at: string
subject: string
from_addr: string | null
received_at: string
body: string | null
}
export interface DigestLink {
url: string
score: number // 2 = job-likely, 1 = other
hint: string
}
export const useDigestStore = defineStore('digest', () => {
const entries = ref<DigestEntry[]>([])
async function fetchAll() {
const { data } = await useApiFetch<DigestEntry[]>('/api/digest-queue')
if (data) entries.value = data
}
async function remove(id: number) {
entries.value = entries.value.filter(e => e.id !== id)
await useApiFetch(`/api/digest-queue/${id}`, { method: 'DELETE' })
}
return { entries, fetchAll, remove }
})
- Step 2: Verify TypeScript compiles
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npm run type-check 2>&1 | tail -10
Expected: no errors
- Step 3: Commit
git add web/src/stores/digest.ts
git commit -m "feat: add digest Pinia store"
Task 6: Digest chip third call — InterviewCard.vue + InterviewsView.vue
Files:
-
Modify:
web/src/components/InterviewCard.vue:87-92 -
Modify:
web/src/views/InterviewsView.vue:105-110 -
Step 1: Add third call in
InterviewCard.vuedigest branch
Find the existing await useApiFetch(…/dismiss…) line in reclassifySignal (line 92). Insert the following 6 lines immediately after that line, inside the if (DISMISS_LABELS.has(newLabel)) block:
// Digest-only: add to browsable queue (fire-and-forget; sig.id === job_contacts.id)
if (newLabel === 'digest') {
useApiFetch('/api/digest-queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_contact_id: sig.id }),
}).catch(() => {})
}
Do NOT modify the else branch or any other part of the function.
- Step 2: Add same third call in
InterviewsView.vuedigest branch
Find the existing await useApiFetch(…/dismiss…) line in reclassifyPreSignal (line 110). Insert the same 6 lines immediately after it:
// Digest-only: add to browsable queue (fire-and-forget; sig.id === job_contacts.id)
if (newLabel === 'digest') {
useApiFetch('/api/digest-queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_contact_id: sig.id }),
}).catch(() => {})
}
Do NOT modify anything else in the function.
- Step 3: Type-check
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npm run type-check 2>&1 | tail -10
Expected: no new errors
- Step 4: Commit
git add web/src/components/InterviewCard.vue web/src/views/InterviewsView.vue
git commit -m "feat: fire digest-queue add call from digest chip handler"
Task 7: Router + Nav — add /digest route and nav item
Files:
-
Modify:
web/src/router/index.ts -
Modify:
web/src/components/AppNav.vue -
Step 1: Add route to
index.ts
After the /interviews route, insert:
{ path: '/digest', component: () => import('../views/DigestView.vue') },
The routes array should now include:
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') },
{ path: '/digest', component: () => import('../views/DigestView.vue') },
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
- Step 2: Update
AppNav.vue— addNewspaperIconto imports
Find the Heroicons import block (around line 74). Add NewspaperIcon to the import:
import {
HomeIcon,
ClipboardDocumentListIcon,
PencilSquareIcon,
CalendarDaysIcon,
LightBulbIcon,
MagnifyingGlassIcon,
NewspaperIcon,
Cog6ToothIcon,
} from '@heroicons/vue/24/outline'
- Step 3: Add Digest to
navLinksarray
Note: This intermediate form will be converted to a
computed()in Step 4 to make the badge reactive. Do not skip Step 4.
After the Interviews entry:
const navLinks = [
{ to: '/', icon: HomeIcon, label: 'Home' },
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
{ to: '/digest', icon: NewspaperIcon, label: 'Digest' },
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },
{ to: '/survey', icon: MagnifyingGlassIcon, label: 'Survey' },
]
- Step 4: Add digest store + reactive badge to
AppNav.vue
The template already has <span v-if="link.badge" class="sidebar__badge"> in the nav loop (line 25) — no template change needed.
In <script setup>, add the digest store import after the existing imports:
import { useDigestStore } from '@/stores/digest'
const digestStore = useDigestStore()
Then change navLinks from a static const array to a computed so the Digest entry's badge stays reactive. Replace the existing const navLinks = [...] with:
const navLinks = computed(() => [
{ to: '/', icon: HomeIcon, label: 'Home' },
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
{ to: '/digest', icon: NewspaperIcon, label: 'Digest',
badge: digestStore.entries.length || undefined },
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },
{ to: '/survey', icon: MagnifyingGlassIcon, label: 'Survey' },
])
computed is already imported at the top of the script (import { ref, computed } from 'vue'). The badge: undefined case hides the badge span (falsy), so no CSS changes needed.
- Step 5: Type-check
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npm run type-check 2>&1 | tail -10
Expected: no errors (DigestView.vue doesn't exist yet — Vue router uses lazy imports so type-check may warn; that's fine)
- Step 6: Commit
git add web/src/router/index.ts web/src/components/AppNav.vue
git commit -m "feat: add Digest tab to nav and router"
Task 8: DigestView.vue — full page UI
Files:
-
Create:
web/src/views/DigestView.vue -
Step 1: Create
DigestView.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useDigestStore, type DigestEntry, type DigestLink } from '@/stores/digest'
import { useApiFetch } from '@/composables/useApi'
const store = useDigestStore()
// Per-entry state keyed by DigestEntry.id
const expandedIds = ref<Record<number, boolean>>({})
const linkResults = ref<Record<number, DigestLink[]>>({})
const selectedUrls = ref<Record<number, Set<string>>>({})
const queueResult = ref<Record<number, { queued: number; skipped: number } | null>>({})
const extracting = ref<Record<number, boolean>>({})
const queuing = ref<Record<number, boolean>>({})
onMounted(() => store.fetchAll())
function toggleExpand(id: number) {
expandedIds.value = { ...expandedIds.value, [id]: !expandedIds.value[id] }
}
// Spread-copy pattern — same as expandedSignalIds in InterviewCard, safe for Vue 3 reactivity
function toggleUrl(entryId: number, url: string) {
const prev = selectedUrls.value[entryId] ?? new Set<string>()
const next = new Set(prev)
next.has(url) ? next.delete(url) : next.add(url)
selectedUrls.value = { ...selectedUrls.value, [entryId]: next }
}
function selectedCount(id: number) {
return selectedUrls.value[id]?.size ?? 0
}
function jobLinks(id: number): DigestLink[] {
return (linkResults.value[id] ?? []).filter(l => l.score >= 2)
}
function otherLinks(id: number): DigestLink[] {
return (linkResults.value[id] ?? []).filter(l => l.score < 2)
}
async function extractLinks(entry: DigestEntry) {
extracting.value = { ...extracting.value, [entry.id]: true }
const { data } = await useApiFetch<{ links: DigestLink[] }>(
`/api/digest-queue/${entry.id}/extract-links`,
{ method: 'POST' },
)
extracting.value = { ...extracting.value, [entry.id]: false }
if (!data) return
linkResults.value = { ...linkResults.value, [entry.id]: data.links }
expandedIds.value = { ...expandedIds.value, [entry.id]: true }
// Pre-check job-likely links
const preChecked = new Set(data.links.filter(l => l.score >= 2).map(l => l.url))
selectedUrls.value = { ...selectedUrls.value, [entry.id]: preChecked }
queueResult.value = { ...queueResult.value, [entry.id]: null }
}
async function queueJobs(entry: DigestEntry) {
const urls = [...(selectedUrls.value[entry.id] ?? [])]
if (!urls.length) return
queuing.value = { ...queuing.value, [entry.id]: true }
const { data } = await useApiFetch<{ queued: number; skipped: number }>(
`/api/digest-queue/${entry.id}/queue-jobs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls }),
},
)
queuing.value = { ...queuing.value, [entry.id]: false }
if (!data) return
queueResult.value = { ...queueResult.value, [entry.id]: data }
linkResults.value = { ...linkResults.value, [entry.id]: [] }
expandedIds.value = { ...expandedIds.value, [entry.id]: false }
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
</script>
<template>
<div class="digest-view">
<h1 class="digest-heading">📰 Digest Queue</h1>
<div v-if="store.entries.length === 0" class="digest-empty">
<span class="empty-bird">🦅</span>
<p>No digest emails queued.</p>
<p class="empty-hint">When you mark an email as 📰 Digest, it appears here.</p>
</div>
<div v-else class="digest-list">
<div v-for="entry in store.entries" :key="entry.id" class="digest-entry">
<!-- Entry header row -->
<div class="entry-header" @click="toggleExpand(entry.id)">
<span class="entry-toggle" aria-hidden="true">{{ expandedIds[entry.id] ? '▾' : '▸' }}</span>
<div class="entry-meta">
<span class="entry-subject">{{ entry.subject }}</span>
<span class="entry-from">
<template v-if="entry.from_addr">From: {{ entry.from_addr }} · </template>
{{ formatDate(entry.received_at) }}
</span>
</div>
<div class="entry-actions" @click.stop>
<button
class="btn-extract"
:disabled="extracting[entry.id]"
:aria-label="linkResults[entry.id]?.length ? 'Re-extract links' : 'Extract job links'"
@click="extractLinks(entry)"
>
{{ linkResults[entry.id]?.length ? 'Re-extract' : 'Extract' }}
</button>
<button
class="btn-dismiss"
aria-label="Remove from digest queue"
@click="store.remove(entry.id)"
>✕</button>
</div>
</div>
<!-- Post-queue confirmation -->
<div v-if="queueResult[entry.id]" class="queue-result">
✅ {{ queueResult[entry.id]!.queued }}
job{{ queueResult[entry.id]!.queued !== 1 ? 's' : '' }} queued for review<template
v-if="queueResult[entry.id]!.skipped > 0"
>, {{ queueResult[entry.id]!.skipped }} skipped (already in pipeline)</template>
</div>
<!-- Expanded: link list -->
<template v-if="expandedIds[entry.id]">
<div v-if="extracting[entry.id]" class="entry-status">Extracting links…</div>
<div v-else-if="linkResults[entry.id] !== undefined && !linkResults[entry.id]!.length" class="entry-status">
No job links found in this email.
</div>
<div v-else-if="linkResults[entry.id]?.length" class="entry-links">
<!-- Job-likely links (score ≥ 2), pre-checked -->
<div class="link-group">
<label
v-for="link in jobLinks(entry.id)"
:key="link.url"
class="link-row"
>
<input
type="checkbox"
class="link-check"
:checked="selectedUrls[entry.id]?.has(link.url)"
@change="toggleUrl(entry.id, link.url)"
/>
<div class="link-text">
<span v-if="link.hint" class="link-hint">{{ link.hint }}</span>
<span class="link-url">{{ link.url }}</span>
</div>
</label>
</div>
<!-- Other links (score = 1), unchecked -->
<template v-if="otherLinks(entry.id).length">
<div class="link-divider">Other links</div>
<div class="link-group">
<label
v-for="link in otherLinks(entry.id)"
:key="link.url"
class="link-row link-row--other"
>
<input
type="checkbox"
class="link-check"
:checked="selectedUrls[entry.id]?.has(link.url)"
@change="toggleUrl(entry.id, link.url)"
/>
<div class="link-text">
<span v-if="link.hint" class="link-hint">{{ link.hint }}</span>
<span class="link-url">{{ link.url }}</span>
</div>
</label>
</div>
</template>
<button
class="btn-queue"
:disabled="selectedCount(entry.id) === 0 || queuing[entry.id]"
@click="queueJobs(entry)"
>
Queue {{ selectedCount(entry.id) > 0 ? selectedCount(entry.id) + ' ' : '' }}selected →
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.digest-view {
padding: var(--space-6);
max-width: 720px;
margin: 0 auto;
}
.digest-heading {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-6);
}
/* Empty state */
.digest-empty {
text-align: center;
padding: var(--space-16) var(--space-8);
color: var(--color-text-muted);
}
.empty-bird { font-size: 2.5rem; display: block; margin-bottom: var(--space-4); }
.empty-hint { font-size: 0.875rem; margin-top: var(--space-2); }
/* Entry list */
.digest-list { display: flex; flex-direction: column; gap: var(--space-3); }
.digest-entry {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: 10px;
overflow: hidden;
}
/* Entry header */
.entry-header {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4);
cursor: pointer;
user-select: none;
}
.entry-toggle { color: var(--color-text-muted); font-size: 0.9rem; flex-shrink: 0; padding-top: 2px; }
.entry-meta { flex: 1; min-width: 0; }
.entry-subject {
display: block;
font-weight: 600;
font-size: 0.9rem;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entry-from { display: block; font-size: 0.75rem; color: var(--color-text-muted); margin-top: 2px; }
.entry-actions { display: flex; gap: var(--space-2); flex-shrink: 0; }
.btn-extract {
font-size: 0.75rem;
padding: 3px 10px;
border-radius: 5px;
border: 1px solid var(--color-border);
background: var(--color-surface-alt);
color: var(--color-text);
cursor: pointer;
transition: border-color 0.1s, color 0.1s;
}
.btn-extract:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
.btn-extract:disabled { opacity: 0.5; cursor: default; }
.btn-dismiss {
font-size: 0.75rem;
padding: 3px 8px;
border-radius: 5px;
border: 1px solid var(--color-border-light);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: border-color 0.1s, color 0.1s;
}
.btn-dismiss:hover { border-color: var(--color-error); color: var(--color-error); }
/* Queue result */
.queue-result {
margin: 0 var(--space-4) var(--space-3);
font-size: 0.8rem;
color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 10%, var(--color-surface-raised));
border-radius: 6px;
padding: var(--space-2) var(--space-3);
}
/* Status messages */
.entry-status {
padding: var(--space-3) var(--space-4) var(--space-4);
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
}
/* Link list */
.entry-links { padding: 0 var(--space-4) var(--space-4); }
.link-group { display: flex; flex-direction: column; gap: 2px; }
.link-row {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: 6px;
cursor: pointer;
background: var(--color-surface);
transition: background 0.1s;
}
.link-row:hover { background: var(--color-surface-alt); }
.link-row--other { opacity: 0.8; }
.link-check { flex-shrink: 0; margin-top: 3px; accent-color: var(--color-primary); cursor: pointer; }
.link-text { min-width: 0; flex: 1; }
.link-hint {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.link-url {
display: block;
font-size: 0.7rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.link-divider {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: var(--space-3) 0 var(--space-2);
border-top: 1px solid var(--color-border-light);
margin-top: var(--space-2);
}
.btn-queue {
margin-top: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-4);
border-radius: 6px;
border: none;
background: var(--color-primary);
color: var(--color-text-inverse);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.1s;
}
.btn-queue:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-queue:disabled { opacity: 0.4; cursor: default; }
@media (max-width: 600px) {
.digest-view { padding: var(--space-4); }
.entry-subject { font-size: 0.85rem; }
}
</style>
- Step 2: Type-check
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npm run type-check 2>&1 | tail -10
Expected: no errors
- Step 3: Smoke-test in browser
Start the dev API and Vue dev server:
# Terminal 1 — dev API
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
conda run -n job-seeker uvicorn dev-api:app --port 8600 --reload
# Terminal 2 — Vue dev server
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npm run dev
Open http://localhost:5173/digest — should see empty state with 🦅 bird.
- Step 4: Commit
git add web/src/views/DigestView.vue
git commit -m "feat: add DigestView with expand/extract/queue UI"
Final check
- Run full test suite one last time
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v 2>&1 | tail -10
Expected: all tests passing
- Type-check
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npm run type-check 2>&1 | tail -5
Expected: no errors