chore(release): v0.9.4
Messaging overhaul: expandable email timeline with lazy body loading, sticky compose bar replacing always-visible action buttons, layout height fixed to 100dvh. Accessibility fixes for contrast failures on orange/amber backgrounds. Theme-aware replacements for hardcoded colors in Interviews, References, and JobReview. Indeed alert parser, Oracle HCM scraper, manage.sh compose engine detection.
This commit is contained in:
parent
5d185650d9
commit
a9fabcf521
14 changed files with 726 additions and 112 deletions
47
CHANGELOG.md
47
CHANGELOG.md
|
|
@ -9,6 +9,53 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
|
||||
---
|
||||
|
||||
## [0.9.4] — 2026-05-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Messages view — expandable email timeline** — click any email item to lazy-load
|
||||
and read the full body inline (HTML stripped to plain text via `DOMParser`).
|
||||
Bodies are fetched on-demand via the new `GET /api/contacts/{id}` endpoint to avoid
|
||||
loading 50KB+ email bodies on every page view.
|
||||
- **Messages view — compose bar** — action buttons (Log call, Log note, Use template,
|
||||
Draft reply with LLM, Call via Osprey) moved from the always-visible header into a
|
||||
sticky bottom compose bar triggered by a + New toggle. Reduces visual clutter when
|
||||
just reading the thread.
|
||||
- **Home view — "Skip review" checkbox** — when adding jobs by URL, a checkbox (default
|
||||
on) sends them directly to the Apply queue, bypassing Job Review.
|
||||
- **ContactsView — sync status** — shows last completed sync time and a spinner when
|
||||
an email sync is running.
|
||||
- **imap_sync: Indeed alert parser** — `parse_indeed_alert()` extracts job title,
|
||||
company, location, salary, and canonical URL from Indeed Job Alert digest emails.
|
||||
- **scrape_url: Oracle HCM support** — Playwright-based scraper for Oracle HCM
|
||||
CandidateExperience portals (React SPAs requiring JS execution).
|
||||
- **manage.sh** — compose engine auto-detection (docker compose / podman compose /
|
||||
podman-compose), `build` command, and cloud/demo stack shortcuts.
|
||||
- **theme.css** — `--color-overlay` token for modal/dialog backdrops.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Messages view layout** — changed `height: 100%` to `height: 100dvh` with a mobile
|
||||
override for the 56px tab bar. `height: 100%` was resolving to "shrink-wrap" because
|
||||
`.app-main` has no explicit height; compose bar is now correctly pinned to the bottom.
|
||||
- **Accessibility: danger button contrast** — `btn--danger` used `color: white` on
|
||||
`--app-accent` (Talon Orange), yielding 2.8:1 contrast (fails WCAG AA 4.5:1 for
|
||||
normal text). Fixed to `color: var(--app-accent-text)` (dark navy, 5.5:1).
|
||||
- **Accessibility: warning badge contrast** — `tab-badge` in Job Review used `color: white`
|
||||
on `--color-warning` (amber). Same fix applied.
|
||||
- **Theme: Interviews signal banners** — hardcoded `rgba(245,158,11,…)` / `rgba(39,174,…)`
|
||||
/ `rgba(192,57,…)` replaced with `color-mix()` against `--color-warning/success/error`.
|
||||
- **Theme: Interviews signal count** — `color: #e67e22` hardcode replaced with
|
||||
`var(--app-accent)`.
|
||||
- **Theme: References academic tag chip** — `color: #7c3aed` hardcode replaced with
|
||||
`var(--status-synced)`; background uses `color-mix()` with the same token.
|
||||
- **Theme: Interviews signal-move button** — `color: #fff` on `--color-primary` fails
|
||||
in dark mode (light green bg); fixed to `var(--color-text-inverse)`.
|
||||
- **Modal backdrops** — `rgba(0,0,0,0.5)` replaced with `var(--color-overlay)` for
|
||||
theme consistency.
|
||||
|
||||
---
|
||||
|
||||
## [0.9.3] — 2026-05-05
|
||||
|
||||
### Added
|
||||
|
|
|
|||
75
dev-api.py
75
dev-api.py
|
|
@ -114,6 +114,38 @@ app.include_router(_feedback_router, prefix="/api/feedback")
|
|||
|
||||
_log = logging.getLogger("peregrine.session")
|
||||
|
||||
# ── Structured auth logging ───────────────────────────────────────────────────
|
||||
# Writes one JSON line per request to /devl/peregrine-logs/auth.log when in
|
||||
# cloud mode. Rotates at 10 MB, keeps 5 files. Also logs to stdout in dev.
|
||||
_AUTH_LOG_DIR = Path(os.environ.get("PEREGRINE_LOG_DIR", "/devl/peregrine-logs"))
|
||||
|
||||
class _JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"msg": record.getMessage(),
|
||||
}
|
||||
if hasattr(record, "auth_event"):
|
||||
payload.update(record.auth_event)
|
||||
return json.dumps(payload)
|
||||
|
||||
def _setup_auth_logging() -> None:
|
||||
from logging.handlers import RotatingFileHandler
|
||||
_AUTH_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
handler = RotatingFileHandler(
|
||||
_AUTH_LOG_DIR / "auth.log", maxBytes=10 * 1024 * 1024, backupCount=5
|
||||
)
|
||||
handler.setFormatter(_JsonFormatter())
|
||||
handler.setLevel(logging.INFO)
|
||||
_log.addHandler(handler)
|
||||
_log.setLevel(logging.DEBUG)
|
||||
|
||||
_setup_auth_logging()
|
||||
|
||||
_seen_users: set[str] = set() # track first-access events within this process lifetime
|
||||
|
||||
|
||||
def _demo_guard() -> None:
|
||||
"""Raise 403 if running in demo mode. Call at the top of any write endpoint."""
|
||||
|
|
@ -158,6 +190,16 @@ def _resolve_cf_user_id(cookie_str: str) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def _auth_log(event: str, **kwargs) -> None:
|
||||
"""Emit a structured INFO log line to the auth logger."""
|
||||
record = logging.LogRecord(
|
||||
name="peregrine.session", level=logging.INFO,
|
||||
pathname="", lineno=0, msg=event, args=(), exc_info=None,
|
||||
)
|
||||
record.auth_event = {"event": event, **kwargs}
|
||||
_log.handle(record)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def cloud_session_middleware(request: Request, call_next):
|
||||
"""In cloud mode, resolve per-user staging.db from the X-CF-Session header."""
|
||||
|
|
@ -165,16 +207,36 @@ async def cloud_session_middleware(request: Request, call_next):
|
|||
cookie_header = request.headers.get("X-CF-Session", "")
|
||||
user_id = _resolve_cf_user_id(cookie_header)
|
||||
if user_id:
|
||||
first_access = user_id not in _seen_users
|
||||
if first_access:
|
||||
_seen_users.add(user_id)
|
||||
user_db = str(_CLOUD_DATA_ROOT / user_id / "peregrine" / "staging.db")
|
||||
if user_db not in _migrated_db_paths:
|
||||
from scripts.db_migrate import migrate_db
|
||||
migrate_db(Path(user_db))
|
||||
_migrated_db_paths.add(user_db)
|
||||
_auth_log(
|
||||
"session_resolved",
|
||||
user_id=user_id,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
first_access=first_access,
|
||||
)
|
||||
token = _request_db.set(user_db)
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
_request_db.reset(token)
|
||||
else:
|
||||
# Only log failures on non-trivial paths (skip health checks / static assets)
|
||||
if request.url.path.startswith("/api/"):
|
||||
_auth_log(
|
||||
"session_failed",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
reason="no_user_id",
|
||||
has_cookie=bool(cookie_header),
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
|
|
@ -1677,6 +1739,16 @@ def list_contacts(job_id: Optional[int] = None, direction: Optional[str] = None,
|
|||
return {"total": total, "contacts": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@app.get("/api/contacts/{contact_id}")
|
||||
def get_contact(contact_id: int):
|
||||
db = _get_db()
|
||||
row = db.execute("SELECT * FROM job_contacts WHERE id = ?", (contact_id,)).fetchone()
|
||||
db.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ── References ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class ReferencePayload(BaseModel):
|
||||
|
|
@ -2112,6 +2184,7 @@ def bulk_purge_jobs(body: BulkPurgeBody):
|
|||
|
||||
class AddJobsBody(BaseModel):
|
||||
urls: List[str]
|
||||
skip_review: bool = True
|
||||
|
||||
|
||||
@app.post("/api/jobs/add", status_code=202)
|
||||
|
|
@ -2123,6 +2196,7 @@ def add_jobs_by_url(body: AddJobsBody):
|
|||
from scripts.task_runner import submit_task
|
||||
db_path = _db_path()
|
||||
existing = get_existing_urls(db_path)
|
||||
status = "approved" if body.skip_review else "pending"
|
||||
queued = 0
|
||||
for raw_url in body.urls:
|
||||
url = canonicalize_url(raw_url.strip())
|
||||
|
|
@ -2132,6 +2206,7 @@ def add_jobs_by_url(body: AddJobsBody):
|
|||
"title": "Importing...", "company": "", "url": url,
|
||||
"source": "manual", "location": "", "description": "",
|
||||
"date_found": _dt.now().isoformat()[:10],
|
||||
"status": status,
|
||||
})
|
||||
if job_id:
|
||||
submit_task(db_path, "scrape_url", job_id)
|
||||
|
|
|
|||
71
manage.sh
71
manage.sh
|
|
@ -15,6 +15,11 @@ cd "$SCRIPT_DIR"
|
|||
|
||||
PROFILE="${PROFILE:-remote}"
|
||||
|
||||
# ── Compose engine detection ──────────────────────────────────────────────────
|
||||
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
|
||||
&& echo "docker compose" \
|
||||
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
|
||||
|
||||
# ── Usage ────────────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
echo ""
|
||||
|
|
@ -28,9 +33,10 @@ usage() {
|
|||
echo -e " ${GREEN}start${NC} Start Peregrine (preflight → up)"
|
||||
echo -e " ${GREEN}stop${NC} Stop all services"
|
||||
echo -e " ${GREEN}restart${NC} Restart all services"
|
||||
echo -e " ${GREEN}build [service]${NC} Rebuild image(s) without restarting (default: api web)"
|
||||
echo -e " ${GREEN}status${NC} Show running containers"
|
||||
echo -e " ${GREEN}logs [service]${NC} Tail logs (default: app)"
|
||||
echo -e " ${GREEN}update${NC} Pull latest images + rebuild app"
|
||||
echo -e " ${GREEN}logs [service]${NC} Tail logs (default: api)"
|
||||
echo -e " ${GREEN}update${NC} Pull latest images + rebuild"
|
||||
echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env"
|
||||
echo -e " ${GREEN}models${NC} Check ollama models in config; pull any missing"
|
||||
echo -e " ${GREEN}test${NC} Run test suite"
|
||||
|
|
@ -41,6 +47,12 @@ usage() {
|
|||
echo -e " ${GREEN}clean${NC} Remove containers, images, volumes (DESTRUCTIVE)"
|
||||
echo -e " ${GREEN}open${NC} Open the web UI in your browser"
|
||||
echo ""
|
||||
echo -e " Cloud / demo commands:"
|
||||
echo -e " ${GREEN}cloud-start${NC} Start the cloud stack (peregrine-cloud)"
|
||||
echo -e " ${GREEN}cloud-restart${NC} Rebuild + restart the cloud stack"
|
||||
echo -e " ${GREEN}demo-start${NC} Start the demo stack (peregrine-demo)"
|
||||
echo -e " ${GREEN}demo-restart${NC} Rebuild + restart the demo stack"
|
||||
echo ""
|
||||
echo " Profiles (set via --profile or PROFILE env var):"
|
||||
echo " remote API-only, no local inference (default)"
|
||||
echo " cpu Local Ollama inference on CPU"
|
||||
|
|
@ -70,7 +82,7 @@ while [[ $# -gt 0 ]]; do
|
|||
esac
|
||||
done
|
||||
|
||||
SERVICE="${1:-app}" # used by `logs` command
|
||||
SERVICE="${1:-api}" # used by `logs` command
|
||||
|
||||
# ── Dependency guard ──────────────────────────────────────────────────────────
|
||||
# Commands that delegate to make; others (status, logs, update, open, setup) run fine without it.
|
||||
|
|
@ -101,7 +113,7 @@ case "$CMD" in
|
|||
start)
|
||||
info "Starting Peregrine (PROFILE=${PROFILE})..."
|
||||
make start PROFILE="$PROFILE"
|
||||
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
|
||||
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
|
||||
success "Peregrine is up → http://localhost:${PORT}"
|
||||
;;
|
||||
|
||||
|
|
@ -114,33 +126,30 @@ case "$CMD" in
|
|||
restart)
|
||||
info "Restarting (PROFILE=${PROFILE})..."
|
||||
make restart PROFILE="$PROFILE"
|
||||
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
|
||||
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
|
||||
success "Peregrine restarted → http://localhost:${PORT}"
|
||||
;;
|
||||
|
||||
status)
|
||||
# Auto-detect compose engine same way Makefile does
|
||||
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
|
||||
&& echo "docker compose" \
|
||||
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
|
||||
$COMPOSE ps
|
||||
;;
|
||||
|
||||
logs)
|
||||
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
|
||||
&& echo "docker compose" \
|
||||
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
|
||||
info "Tailing logs for: ${SERVICE}"
|
||||
$COMPOSE logs -f "$SERVICE"
|
||||
;;
|
||||
|
||||
build)
|
||||
BUILD_SVC="$([[ "${SERVICE}" == "api" ]] && echo "api web" || echo "${SERVICE}")"
|
||||
info "Building ${BUILD_SVC}..."
|
||||
$COMPOSE build $BUILD_SVC
|
||||
success "Build complete. Run './manage.sh restart' to apply."
|
||||
;;
|
||||
|
||||
update)
|
||||
info "Pulling latest images and rebuilding app..."
|
||||
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
|
||||
&& echo "docker compose" \
|
||||
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
|
||||
info "Pulling latest images and rebuilding..."
|
||||
$COMPOSE pull searxng ollama 2>/dev/null || true
|
||||
$COMPOSE build app web
|
||||
$COMPOSE build api web
|
||||
success "Update complete. Run './manage.sh restart' to apply."
|
||||
;;
|
||||
|
||||
|
|
@ -167,7 +176,7 @@ case "$CMD" in
|
|||
;;
|
||||
|
||||
open)
|
||||
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
|
||||
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
|
||||
URL="http://localhost:${PORT}"
|
||||
info "Opening ${URL}"
|
||||
if command -v xdg-open &>/dev/null; then
|
||||
|
|
@ -197,6 +206,32 @@ case "$CMD" in
|
|||
-v "${@:3}"
|
||||
;;
|
||||
|
||||
cloud-start)
|
||||
info "Starting cloud stack (peregrine-cloud)..."
|
||||
$COMPOSE -f compose.cloud.yml --project-name peregrine-cloud up -d
|
||||
success "Cloud stack up → http://localhost:8508"
|
||||
;;
|
||||
|
||||
cloud-restart)
|
||||
info "Rebuilding + restarting cloud stack (peregrine-cloud)..."
|
||||
$COMPOSE -f compose.cloud.yml --project-name peregrine-cloud build api web
|
||||
$COMPOSE -f compose.cloud.yml --project-name peregrine-cloud up -d
|
||||
success "Cloud stack restarted → http://localhost:8508"
|
||||
;;
|
||||
|
||||
demo-start)
|
||||
info "Starting demo stack (peregrine-demo)..."
|
||||
$COMPOSE -f compose.demo.yml --project-name peregrine-demo up -d
|
||||
success "Demo stack up → http://localhost:8504"
|
||||
;;
|
||||
|
||||
demo-restart)
|
||||
info "Rebuilding + restarting demo stack (peregrine-demo)..."
|
||||
$COMPOSE -f compose.demo.yml --project-name peregrine-demo build api web
|
||||
$COMPOSE -f compose.demo.yml --project-name peregrine-demo up -d
|
||||
success "Demo stack restarted → http://localhost:8504"
|
||||
;;
|
||||
|
||||
help|--help|-h)
|
||||
usage
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -234,10 +234,11 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
|
|||
return None
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
status = job.get("status", "pending")
|
||||
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, date_posted, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
job.get("title", ""),
|
||||
job.get("company", ""),
|
||||
|
|
@ -249,6 +250,7 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
|
|||
job.get("description", ""),
|
||||
job.get("date_found", ""),
|
||||
job.get("date_posted", "") or "",
|
||||
status,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ def _has_todo_keyword(subject: str) -> bool:
|
|||
|
||||
|
||||
_LINKEDIN_ALERT_SENDER = "jobalerts-noreply@linkedin.com"
|
||||
_INDEED_ALERT_SENDER = "jobalerts@indeed.com"
|
||||
|
||||
# Social-proof / nav lines to skip when parsing alert blocks
|
||||
_ALERT_SKIP_PHRASES = {
|
||||
|
|
@ -447,6 +448,75 @@ def parse_linkedin_alert(body: str) -> list[dict]:
|
|||
return jobs
|
||||
|
||||
|
||||
def parse_indeed_alert(body: str) -> list[dict]:
|
||||
"""
|
||||
Parse the HTML body of an Indeed Job Alert email.
|
||||
|
||||
Returns a list of dicts: {title, company, location, salary, url}.
|
||||
URL is canonicalised to https://www.indeed.com/viewjob?jk=<id>
|
||||
(tracking parameters stripped).
|
||||
"""
|
||||
try:
|
||||
from bs4 import BeautifulSoup as _BS
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
jobs: list[dict] = []
|
||||
soup = _BS(body, "html.parser")
|
||||
|
||||
# Each job card is an <a> wrapping a job title — Indeed uses several layouts
|
||||
# across their email templates. We try two strategies:
|
||||
#
|
||||
# Strategy A (2023+ layout): <td> blocks containing an <a> with /viewjob?jk=
|
||||
# Strategy B (older layout): <tr class="job"> blocks
|
||||
#
|
||||
# Both extract the canonical jk= key from the href.
|
||||
|
||||
seen_jks: set[str] = set()
|
||||
|
||||
for anchor in soup.find_all("a", href=True):
|
||||
href: str = anchor["href"]
|
||||
jk_m = re.search(r"[?&]jk=([a-z0-9]+)", href, re.IGNORECASE)
|
||||
if not jk_m:
|
||||
continue
|
||||
jk = jk_m.group(1)
|
||||
if jk in seen_jks:
|
||||
continue
|
||||
seen_jks.add(jk)
|
||||
|
||||
title = anchor.get_text(separator=" ", strip=True)
|
||||
if not title or len(title) < 3:
|
||||
continue
|
||||
|
||||
# Walk up to find the container cell/row and extract company + location
|
||||
container = anchor.find_parent(["td", "tr", "div"])
|
||||
company = location = salary = ""
|
||||
if container:
|
||||
text_lines = [
|
||||
t.strip() for t in container.get_text(separator="\n").splitlines()
|
||||
if t.strip() and t.strip().lower() != title.lower()
|
||||
]
|
||||
if text_lines:
|
||||
company = text_lines[0]
|
||||
if len(text_lines) > 1:
|
||||
location = text_lines[1]
|
||||
# salary line often contains "$" or "/yr"
|
||||
for line in text_lines[2:]:
|
||||
if "$" in line or "/yr" in line.lower() or "/hour" in line.lower():
|
||||
salary = line
|
||||
break
|
||||
|
||||
jobs.append({
|
||||
"title": title,
|
||||
"company": company,
|
||||
"location": location,
|
||||
"salary": salary,
|
||||
"url": f"https://www.indeed.com/viewjob?jk={jk}",
|
||||
})
|
||||
|
||||
return jobs
|
||||
|
||||
|
||||
def _scan_todo_label(conn: imaplib.IMAP4, cfg: dict, db_path: Path,
|
||||
active_jobs: list[dict],
|
||||
known_message_ids: set) -> int:
|
||||
|
|
@ -558,20 +628,29 @@ def _scan_unmatched_leads(conn: imaplib.IMAP4, cfg: dict,
|
|||
if mid in known_message_ids:
|
||||
continue
|
||||
|
||||
# ── LinkedIn Job Alert digest — parse each card individually ──────
|
||||
if _LINKEDIN_ALERT_SENDER in parsed["from_addr"].lower():
|
||||
cards = parse_linkedin_alert(parsed["body"])
|
||||
for card in cards:
|
||||
# ── Job alert digests — parse each card deterministically ───────
|
||||
from_lower = parsed["from_addr"].lower()
|
||||
alert_cards: list[dict] = []
|
||||
alert_source = ""
|
||||
if _LINKEDIN_ALERT_SENDER in from_lower:
|
||||
alert_cards = parse_linkedin_alert(parsed["body"])
|
||||
alert_source = "linkedin"
|
||||
elif _INDEED_ALERT_SENDER in from_lower:
|
||||
alert_cards = parse_indeed_alert(parsed["body"])
|
||||
alert_source = "indeed"
|
||||
|
||||
if alert_cards:
|
||||
for card in alert_cards:
|
||||
if card["url"] in existing_urls:
|
||||
continue
|
||||
job_id = insert_job(db_path, {
|
||||
"title": card["title"],
|
||||
"company": card["company"],
|
||||
"url": card["url"],
|
||||
"source": "linkedin",
|
||||
"location": card["location"],
|
||||
"is_remote": 0,
|
||||
"salary": "",
|
||||
"title": card["title"],
|
||||
"company": card["company"],
|
||||
"url": card["url"],
|
||||
"source": alert_source,
|
||||
"location": card.get("location", ""),
|
||||
"is_remote": 0,
|
||||
"salary": card.get("salary", ""),
|
||||
"description": "",
|
||||
"date_found": datetime.now().isoformat()[:10],
|
||||
})
|
||||
|
|
@ -580,7 +659,7 @@ def _scan_unmatched_leads(conn: imaplib.IMAP4, cfg: dict,
|
|||
submit_task(db_path, "scrape_url", job_id)
|
||||
existing_urls.add(card["url"])
|
||||
new_leads += 1
|
||||
print(f"[imap] LinkedIn alert → {card['company']} — {card['title']}")
|
||||
print(f"[imap] {alert_source} alert → {card['company']} — {card['title']}")
|
||||
known_message_ids.add(mid)
|
||||
continue # skip normal LLM extraction path
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ _TIMEOUT = 12
|
|||
|
||||
|
||||
def _detect_board(url: str) -> str:
|
||||
"""Return 'linkedin', 'indeed', 'glassdoor', or 'generic'."""
|
||||
"""Return 'linkedin', 'indeed', 'glassdoor', 'jobgether', 'oracle_hcm', or 'generic'."""
|
||||
url_lower = url.lower()
|
||||
if "linkedin.com" in url_lower:
|
||||
return "linkedin"
|
||||
|
|
@ -67,6 +67,8 @@ def _detect_board(url: str) -> str:
|
|||
return "glassdoor"
|
||||
if "jobgether.com" in url_lower:
|
||||
return "jobgether"
|
||||
if "oraclecloud.com" in url_lower and "hcmui" in url_lower:
|
||||
return "oracle_hcm"
|
||||
return "generic"
|
||||
|
||||
|
||||
|
|
@ -201,6 +203,70 @@ def _scrape_jobgether(url: str) -> dict:
|
|||
return {"company": company, "source": "jobgether"} if company else {}
|
||||
|
||||
|
||||
def _scrape_oracle_hcm(url: str) -> dict:
|
||||
"""Scrape an Oracle HCM CandidateExperience job page via Playwright.
|
||||
|
||||
Oracle HCM portals are React SPAs that require JS execution. The prospect
|
||||
token in the URL path grants public access — no auth needed.
|
||||
"""
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
print("[scrape_url] Oracle HCM: Playwright not installed, falling back to generic")
|
||||
return _scrape_generic(url)
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
try:
|
||||
ctx = browser.new_context(user_agent=_HEADERS["User-Agent"])
|
||||
page = ctx.new_page()
|
||||
page.goto(url, timeout=30_000)
|
||||
page.wait_for_load_state("networkidle", timeout=20_000)
|
||||
|
||||
result = page.evaluate("""() => {
|
||||
const sel = (s) => document.querySelector(s)?.textContent?.trim() || '';
|
||||
const selInner = (s) => document.querySelector(s)?.innerText?.trim() || '';
|
||||
|
||||
// Title: try known HCM selectors then fall back to first h1
|
||||
const title = sel('[class*="requisition-title"]')
|
||||
|| sel('[class*="JobTitle"]')
|
||||
|| sel('.job-title')
|
||||
|| sel('h1');
|
||||
|
||||
// Company: page header logo alt text, meta, or site-name span
|
||||
const companyMeta = document.querySelector('meta[property="og:site_name"]')
|
||||
?.getAttribute('content') || '';
|
||||
const company = sel('[class*="company-name"]')
|
||||
|| sel('[class*="siteName"]')
|
||||
|| sel('[class*="site-name"]')
|
||||
|| companyMeta;
|
||||
|
||||
// Location: job detail list items
|
||||
const location = sel('[class*="job-location"]')
|
||||
|| sel('[data-testid*="location"]')
|
||||
|| sel('[class*="location"]');
|
||||
|
||||
// Description: main content div
|
||||
const description = selInner('[class*="job-description"]')
|
||||
|| selInner('[class*="requisition-description"]')
|
||||
|| selInner('[class*="JobDescription"]')
|
||||
|| selInner('main article')
|
||||
|| selInner('main');
|
||||
|
||||
return { title, company, location, description };
|
||||
}""")
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
result["source"] = "oracle_hcm"
|
||||
return {k: v for k, v in result.items() if v}
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[scrape_url] Oracle HCM Playwright error for {url}: {exc}")
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_json_ld_or_og(html: str) -> dict:
|
||||
"""Extract job fields from JSON-LD structured data, then og: meta tags."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
|
@ -278,6 +344,8 @@ def scrape_job_url(db_path: Path = DEFAULT_DB, job_id: int = None) -> dict:
|
|||
fields = _scrape_glassdoor(url)
|
||||
elif board == "jobgether":
|
||||
fields = _scrape_jobgether(url)
|
||||
elif board == "oracle_hcm":
|
||||
fields = _scrape_oracle_hcm(url)
|
||||
else:
|
||||
fields = _scrape_generic(url)
|
||||
except requests.RequestException as exc:
|
||||
|
|
|
|||
|
|
@ -203,6 +203,73 @@ def test_parse_linkedin_alert_empty_body():
|
|||
assert parse_linkedin_alert("No jobs here.") == []
|
||||
|
||||
|
||||
# ── Indeed alert parser ───────────────────────────────────────────────────────
|
||||
|
||||
_INDEED_ALERT_HTML = """
|
||||
<html><body>
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.indeed.com/viewjob?jk=abc123def456&utm_source=jobseeker_email">
|
||||
Senior Python Engineer
|
||||
</a>
|
||||
<br/>Acme Corp<br/>San Francisco, CA<br/>$130,000 - $160,000 a year
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.indeed.com/viewjob?jk=999zzzqqq111&trk=email_alert">
|
||||
Staff Backend Engineer
|
||||
</a>
|
||||
<br/>Widgets Inc<br/>Remote
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.indeed.com/rc/clk?jk=abc123def456&pos=0">Duplicate link</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
def test_parse_indeed_alert_extracts_jobs():
|
||||
from scripts.imap_sync import parse_indeed_alert
|
||||
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
|
||||
assert len(jobs) == 2
|
||||
assert jobs[0]["title"] == "Senior Python Engineer"
|
||||
assert jobs[0]["url"] == "https://www.indeed.com/viewjob?jk=abc123def456"
|
||||
assert jobs[1]["title"] == "Staff Backend Engineer"
|
||||
assert jobs[1]["url"] == "https://www.indeed.com/viewjob?jk=999zzzqqq111"
|
||||
|
||||
|
||||
def test_parse_indeed_alert_strips_tracking_params():
|
||||
from scripts.imap_sync import parse_indeed_alert
|
||||
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
|
||||
for job in jobs:
|
||||
assert "utm_source" not in job["url"]
|
||||
assert "trk=" not in job["url"]
|
||||
|
||||
|
||||
def test_parse_indeed_alert_deduplicates_jk():
|
||||
from scripts.imap_sync import parse_indeed_alert
|
||||
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
|
||||
urls = [j["url"] for j in jobs]
|
||||
assert len(urls) == len(set(urls))
|
||||
|
||||
|
||||
def test_parse_indeed_alert_empty_body():
|
||||
from scripts.imap_sync import parse_indeed_alert
|
||||
assert parse_indeed_alert("") == []
|
||||
assert parse_indeed_alert("<html><body>No jobs here</body></html>") == []
|
||||
|
||||
|
||||
def test_parse_indeed_alert_extracts_salary():
|
||||
from scripts.imap_sync import parse_indeed_alert
|
||||
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
|
||||
assert "$130,000" in jobs[0]["salary"]
|
||||
|
||||
|
||||
# ── _scan_unmatched_leads integration ─────────────────────────────────────────
|
||||
|
||||
_ALERT_BODY = """\
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@
|
|||
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
|
||||
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
|
||||
|
||||
/* Overlay — modal/dialog scrim */
|
||||
--color-overlay: rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Transitions */
|
||||
--transition: 200ms ease;
|
||||
--transition-slow: 400ms ease;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
import HintChip from '../components/HintChip.vue'
|
||||
import { useAppConfigStore } from '../stores/appConfig'
|
||||
|
|
@ -19,13 +19,15 @@ interface Contact {
|
|||
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 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('')
|
||||
const syncing = ref(false)
|
||||
const syncStatus = ref<{ status: string; last_completed_at: string | null } | null>(null)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function fetchContacts() {
|
||||
|
|
@ -76,9 +78,45 @@ const signalLabel: Record<string, string> = {
|
|||
rejected: '✖ Rejected',
|
||||
positive_response: '✅ Positive',
|
||||
survey_received: '📋 Survey',
|
||||
event_rescheduled: '🔄 Rescheduled',
|
||||
neutral: '— Neutral',
|
||||
}
|
||||
|
||||
onMounted(fetchContacts)
|
||||
async function fetchSyncStatus() {
|
||||
const { data } = await useApiFetch<{ status: string; last_completed_at: string | null }>(
|
||||
'/api/email/sync/status'
|
||||
)
|
||||
if (data) syncStatus.value = data
|
||||
}
|
||||
|
||||
async function triggerSync() {
|
||||
syncing.value = true
|
||||
await useApiFetch('/api/tasks/email-sync', { method: 'POST' })
|
||||
// Poll until the task finishes or we give up after 60 s
|
||||
const deadline = Date.now() + 60_000
|
||||
const poll = setInterval(async () => {
|
||||
await fetchSyncStatus()
|
||||
if (syncStatus.value?.status === 'completed' || Date.now() > deadline) {
|
||||
clearInterval(poll)
|
||||
syncing.value = false
|
||||
fetchContacts()
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function formatSyncTime(iso: string | null): string {
|
||||
if (!iso) return 'never'
|
||||
const d = new Date(iso)
|
||||
const diff = Date.now() - d.getTime()
|
||||
if (diff < 60_000) return 'just now'
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchContacts(), fetchSyncStatus()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -91,6 +129,20 @@ onMounted(fetchContacts)
|
|||
<header class="contacts-header">
|
||||
<h1 class="contacts-title">Contacts</h1>
|
||||
<span class="contacts-count" v-if="total > 0">{{ total }} total</span>
|
||||
<div class="contacts-sync">
|
||||
<span v-if="syncStatus" class="sync-last">
|
||||
Last sync: {{ formatSyncTime(syncStatus.last_completed_at) }}
|
||||
</span>
|
||||
<button
|
||||
class="btn-sync"
|
||||
:disabled="syncing"
|
||||
@click="triggerSync"
|
||||
:aria-label="syncing ? 'Email sync running' : 'Sync email now'"
|
||||
>
|
||||
<span :class="['sync-icon', { 'sync-icon--spinning': syncing }]">↻</span>
|
||||
{{ syncing ? 'Syncing…' : 'Sync email' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="contacts-toolbar">
|
||||
|
|
@ -115,8 +167,16 @@ onMounted(fetchContacts)
|
|||
|
||||
<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 && !search" class="contacts-empty contacts-empty--setup">
|
||||
<p>No contacts yet.</p>
|
||||
<p class="contacts-empty-hint">
|
||||
Connect your inbox in
|
||||
<a href="/settings?tab=connections" class="setup-link">Settings → Connections</a>
|
||||
then hit <strong>Sync email</strong> to import recruiter emails automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="contacts.length === 0" class="contacts-empty">
|
||||
No contacts found{{ search ? ' for that search' : '' }}.
|
||||
No contacts found for that search.
|
||||
</div>
|
||||
|
||||
<div v-else class="contacts-table-wrap">
|
||||
|
|
@ -339,4 +399,69 @@ onMounted(fetchContacts)
|
|||
.text-muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.contacts-sync {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sync-last {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-sync {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 7px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-sync:hover:not(:disabled) {
|
||||
border-color: var(--app-primary);
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.btn-sync:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sync-icon {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.sync-icon--spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.contacts-empty--setup {
|
||||
padding: var(--space-10) var(--space-4);
|
||||
}
|
||||
|
||||
.contacts-empty-hint {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.setup-link {
|
||||
color: var(--app-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -159,6 +159,10 @@
|
|||
rows="4"
|
||||
aria-label="Job URLs to add"
|
||||
/>
|
||||
<label class="add-jobs__skip-review">
|
||||
<input type="checkbox" v-model="skipReview" />
|
||||
Skip review — add directly to Apply queue
|
||||
</label>
|
||||
<button
|
||||
class="action-btn action-btn--primary"
|
||||
:disabled="!urlInput.trim()"
|
||||
|
|
@ -437,15 +441,16 @@ const runEnrich = () => runTask('enrich', '/api/tasks/enrich')
|
|||
|
||||
// ── Add jobs ───────────────────────────────────────────────────────────────
|
||||
|
||||
const addTab = ref<'url' | 'csv'>('url')
|
||||
const urlInput = ref('')
|
||||
const addTab = ref<'url' | 'csv'>('url')
|
||||
const urlInput = ref('')
|
||||
const skipReview = ref(true)
|
||||
|
||||
async function addByUrl() {
|
||||
const urls = urlInput.value.split('\n').map(u => u.trim()).filter(Boolean)
|
||||
await useApiFetch('/api/jobs/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ urls }),
|
||||
body: JSON.stringify({ urls, skip_review: skipReview.value }),
|
||||
})
|
||||
urlInput.value = ''
|
||||
store.refresh()
|
||||
|
|
@ -791,6 +796,16 @@ onUnmounted(() => {
|
|||
|
||||
.add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
|
||||
|
||||
.add-jobs__skip-review {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Danger Zone ──────────────────────────────────────── */
|
||||
|
||||
.danger-zone {
|
||||
|
|
|
|||
|
|
@ -682,7 +682,7 @@ function formatRejectionDate(job: PipelineJob): string {
|
|||
padding: 1px 8px; font-size: 0.75em; font-weight: 700; margin-left: var(--space-1);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: #e67e22; }
|
||||
.pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: var(--app-accent); }
|
||||
|
||||
/* Collapsible pre-list body */
|
||||
.pre-list-body {
|
||||
|
|
@ -713,15 +713,15 @@ function formatRejectionDate(job: PipelineJob): string {
|
|||
border-top: 1px solid transparent;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.pre-signal-banner[data-color="amber"] { background: rgba(245,158,11,0.08); border-top-color: rgba(245,158,11,0.4); }
|
||||
.pre-signal-banner[data-color="green"] { background: rgba(39,174,96,0.08); border-top-color: rgba(39,174,96,0.4); }
|
||||
.pre-signal-banner[data-color="red"] { background: rgba(192,57,43,0.08); border-top-color: rgba(192,57,43,0.4); }
|
||||
.pre-signal-banner[data-color="amber"] { background: color-mix(in srgb, var(--color-warning) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-warning) 40%, transparent); }
|
||||
.pre-signal-banner[data-color="green"] { background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-success) 40%, transparent); }
|
||||
.pre-signal-banner[data-color="red"] { background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-error) 40%, transparent); }
|
||||
|
||||
.signal-label { font-size: 0.82em; }
|
||||
.signal-subject { font-size: 0.78em; color: var(--color-text-muted); }
|
||||
.signal-actions { display: flex; gap: 6px; align-items: center; }
|
||||
.btn-signal-move {
|
||||
background: var(--color-primary); color: #fff;
|
||||
background: var(--color-primary); color: var(--color-text-inverse);
|
||||
border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer;
|
||||
}
|
||||
.btn-signal-dismiss {
|
||||
|
|
|
|||
|
|
@ -496,7 +496,7 @@ onUnmounted(() => {
|
|||
|
||||
.tab-badge {
|
||||
background: var(--color-warning);
|
||||
color: white;
|
||||
color: var(--app-accent-text);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
|
|
|
|||
|
|
@ -34,35 +34,8 @@
|
|||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Action bar -->
|
||||
<div class="action-bar" role="toolbar" aria-label="Message actions">
|
||||
<button class="btn btn--ghost" @click="openLogModal('call_note')">Log call</button>
|
||||
<button class="btn btn--ghost" @click="openLogModal('in_person')">Log note</button>
|
||||
<button class="btn btn--ghost" @click="openTemplateModal('apply')">Use template</button>
|
||||
<button
|
||||
class="btn btn--primary"
|
||||
:disabled="store.loading"
|
||||
@click="requestDraft"
|
||||
>
|
||||
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
|
||||
</button>
|
||||
|
||||
<!-- Osprey (Phase 2 stub) — aria-disabled, never hidden -->
|
||||
<button
|
||||
class="btn btn--osprey"
|
||||
aria-disabled="true"
|
||||
:title="ospreyTitle"
|
||||
@mouseenter="handleOspreyHover"
|
||||
@focus="handleOspreyHover"
|
||||
>
|
||||
📞 Call via Osprey
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Draft pending announcement (screen reader) -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||
{{ draftAnnouncement }}
|
||||
</div>
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{{ draftAnnouncement }}</div>
|
||||
|
||||
<!-- Error banner -->
|
||||
<p v-if="store.error" class="thread-error" role="alert">{{ store.error }}</p>
|
||||
|
|
@ -76,9 +49,15 @@
|
|||
v-for="item in timeline"
|
||||
:key="item._key"
|
||||
class="timeline__item"
|
||||
:class="[`timeline__item--${item.type}`, item.approved_at === null && item.type === 'draft' ? 'timeline__item--draft-pending' : '']"
|
||||
:class="[
|
||||
`timeline__item--${item.type}`,
|
||||
item.approved_at === null && item.type === 'draft' ? 'timeline__item--draft-pending' : '',
|
||||
item.type !== 'draft' ? 'timeline__item--expandable' : '',
|
||||
expandedKeys.has(item._key) ? 'timeline__item--open' : '',
|
||||
]"
|
||||
role="listitem"
|
||||
:aria-label="`${typeLabel(item.type)}, ${item.direction || ''}, ${item.logged_at}`"
|
||||
@click="item.type !== 'draft' && toggleExpand(item)"
|
||||
>
|
||||
<span class="timeline__icon" aria-hidden="true">{{ typeIcon(item.type) }}</span>
|
||||
<div class="timeline__content">
|
||||
|
|
@ -89,19 +68,29 @@
|
|||
<span
|
||||
v-if="item.type === 'draft' && item.approved_at === null"
|
||||
class="timeline__badge timeline__badge--pending"
|
||||
>
|
||||
Pending approval
|
||||
</span>
|
||||
>Pending approval</span>
|
||||
<span
|
||||
v-if="item.type === 'draft' && item.approved_at !== null"
|
||||
class="timeline__badge timeline__badge--approved"
|
||||
>
|
||||
Approved
|
||||
>Approved</span>
|
||||
<span v-if="item.type !== 'draft'" class="timeline__expand-hint" aria-hidden="true">
|
||||
{{ expandedKeys.has(item._key) ? '▲' : '▼' }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="item.subject" class="timeline__subject">{{ item.subject }}</p>
|
||||
|
||||
<!-- Draft body is editable before approval -->
|
||||
<!-- Expandable body for non-draft items -->
|
||||
<template v-if="item.type !== 'draft' && expandedKeys.has(item._key)">
|
||||
<div class="timeline__body-wrap" @click.stop>
|
||||
<div v-if="bodyCache[item.id] === null" class="timeline__body-loading">
|
||||
Loading…
|
||||
</div>
|
||||
<pre v-else-if="bodyCache[item.id]" class="timeline__body">{{ bodyCache[item.id] }}</pre>
|
||||
<p v-else class="timeline__body-empty">No body content.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Draft: editable textarea + actions -->
|
||||
<template v-if="item.type === 'draft' && item.approved_at === null">
|
||||
<textarea
|
||||
:ref="el => setDraftRef(item.id, el)"
|
||||
|
|
@ -119,25 +108,50 @@
|
|||
v-if="item.to_addr"
|
||||
:href="`mailto:${item.to_addr}?subject=${encodeURIComponent(item.subject ?? '')}&body=${encodeURIComponent(item.body ?? '')}`"
|
||||
class="btn btn--ghost btn--sm"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Open in email client
|
||||
</a>
|
||||
target="_blank" rel="noopener"
|
||||
>Open in email client</a>
|
||||
<button class="btn btn--ghost btn--sm btn--danger" @click="confirmDelete(item.id)">
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="timeline__body">{{ item.body }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="timeline.length === 0" class="timeline__empty">
|
||||
No messages logged yet for this job.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Compose bar (sticky footer) -->
|
||||
<div class="compose-bar" role="toolbar" aria-label="Compose actions">
|
||||
<div v-if="composing" class="compose-bar__actions">
|
||||
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openLogModal('call_note'))">Log call</button>
|
||||
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openLogModal('in_person'))">Log note</button>
|
||||
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openTemplateModal('apply'))">Use template</button>
|
||||
<button
|
||||
class="btn btn--primary btn--sm"
|
||||
:disabled="store.loading"
|
||||
@click="triggerAction(requestDraft)"
|
||||
>
|
||||
<span v-if="store.loading" class="btn__spinner" aria-hidden="true"></span>
|
||||
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--osprey btn--sm"
|
||||
aria-disabled="true"
|
||||
:title="ospreyTitle"
|
||||
@mouseenter="handleOspreyHover"
|
||||
@focus="handleOspreyHover"
|
||||
>📞 Call via Osprey</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn compose-bar__toggle"
|
||||
:class="composing ? 'btn--ghost' : 'btn--primary'"
|
||||
@click="composing = !composing"
|
||||
:aria-expanded="composing"
|
||||
aria-controls="compose-actions"
|
||||
>{{ composing ? '✕ Close' : '+ New' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
|
|
@ -230,8 +244,8 @@ const jobContacts = ref<JobContact[]>([])
|
|||
|
||||
watch(selectedJobId, async (id) => {
|
||||
if (id === null) { jobContacts.value = []; return }
|
||||
const { data } = await useApiFetch<JobContact[]>(`/api/contacts?job_id=${id}`)
|
||||
jobContacts.value = data ?? []
|
||||
const { data } = await useApiFetch<{ total: number; contacts: JobContact[] }>(`/api/contacts?job_id=${id}`)
|
||||
jobContacts.value = data?.contacts ?? []
|
||||
})
|
||||
|
||||
const timeline = computed<TimelineItem[]>(() => {
|
||||
|
|
@ -262,6 +276,31 @@ const timeline = computed<TimelineItem[]>(() => {
|
|||
)
|
||||
})
|
||||
|
||||
// ── Body expansion ────────────────────────────────────────────────────────
|
||||
const expandedKeys = ref(new Set<string>())
|
||||
const bodyCache = ref<Record<number, string | null>>({}) // null = still loading
|
||||
|
||||
async function toggleExpand(item: TimelineItem) {
|
||||
const key = item._key
|
||||
const next = new Set(expandedKeys.value)
|
||||
if (next.has(key)) { next.delete(key); expandedKeys.value = next; return }
|
||||
next.add(key)
|
||||
expandedKeys.value = next
|
||||
if (key.startsWith('jc-') && !(item.id in bodyCache.value)) {
|
||||
bodyCache.value = { ...bodyCache.value, [item.id]: null }
|
||||
const { data } = await useApiFetch<{ body: string | null }>(`/api/contacts/${item.id}`)
|
||||
const raw = data?.body ?? ''
|
||||
const text = raw.trimStart().startsWith('<')
|
||||
? (new DOMParser().parseFromString(raw, 'text/html').body.textContent ?? '').trim()
|
||||
: raw.trim()
|
||||
bodyCache.value = { ...bodyCache.value, [item.id]: text }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compose bar ────────────────────────────────────────────────────────────
|
||||
const composing = ref(false)
|
||||
function triggerAction(fn: () => void) { composing.value = false; fn() }
|
||||
|
||||
// ── Draft body edits (local, before approve) ──────────────────────────────
|
||||
|
||||
const draftBodyEdits = ref<Record<number, string>>({})
|
||||
|
|
@ -415,8 +454,15 @@ onUnmounted(() => {
|
|||
<style scoped>
|
||||
.messaging-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.messaging-layout {
|
||||
height: calc(100dvh - 56px - env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Left panel ─────────────────────── */
|
||||
|
|
@ -465,11 +511,6 @@ onUnmounted(() => {
|
|||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.action-bar {
|
||||
display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
.btn--osprey {
|
||||
opacity: 0.5; cursor: not-allowed;
|
||||
background: none; border: 1px dashed var(--color-border);
|
||||
|
|
@ -477,6 +518,21 @@ onUnmounted(() => {
|
|||
color: var(--color-text-muted); font-size: var(--text-sm);
|
||||
padding: var(--space-2) var(--space-3); min-height: 36px;
|
||||
}
|
||||
|
||||
/* Compose bar */
|
||||
.compose-bar {
|
||||
flex-shrink: 0;
|
||||
display: flex; flex-direction: column; align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.compose-bar__actions {
|
||||
display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
|
||||
width: 100%; justify-content: flex-start;
|
||||
}
|
||||
.compose-bar__toggle { align-self: flex-end; min-width: 90px; justify-content: center; }
|
||||
.thread-error {
|
||||
margin: var(--space-2) var(--space-4);
|
||||
color: var(--app-accent); font-size: var(--text-sm);
|
||||
|
|
@ -507,10 +563,27 @@ onUnmounted(() => {
|
|||
font-size: var(--text-xs); font-weight: 700;
|
||||
padding: 1px 6px; border-radius: var(--radius-full);
|
||||
}
|
||||
.timeline__badge--pending { background: #fef3c7; color: #d97706; }
|
||||
.timeline__badge--approved { background: #d1fae5; color: #065f46; }
|
||||
.timeline__badge--pending { background: var(--color-accent-light); color: var(--color-accent); }
|
||||
.timeline__badge--approved { background: var(--color-primary-light); color: var(--color-primary); }
|
||||
.timeline__subject { font-size: var(--text-sm); font-weight: 500; margin: 0; }
|
||||
.timeline__body { font-size: var(--text-sm); white-space: pre-wrap; margin: 0; color: var(--color-text); }
|
||||
.timeline__expand-hint {
|
||||
font-size: var(--text-xs); color: var(--color-text-muted); margin-left: auto;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
.timeline__item--expandable { cursor: pointer; }
|
||||
.timeline__item--expandable:hover { border-color: var(--app-primary); }
|
||||
.timeline__body-wrap {
|
||||
margin-top: var(--space-2);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
.timeline__body {
|
||||
font-size: var(--text-sm); white-space: pre-wrap; margin: 0;
|
||||
color: var(--color-text); max-height: 280px; overflow-y: auto;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
.timeline__body-loading { font-size: var(--text-xs); color: var(--color-text-muted); }
|
||||
.timeline__body-empty { font-size: var(--text-xs); color: var(--color-text-muted); margin: 0; }
|
||||
.timeline__draft-body {
|
||||
width: 100%; font-size: var(--text-sm); font-family: var(--font-body);
|
||||
padding: var(--space-2); border: 1px solid var(--color-border);
|
||||
|
|
@ -522,20 +595,45 @@ onUnmounted(() => {
|
|||
.timeline__empty { color: var(--color-text-muted); font-size: var(--text-sm); padding: var(--space-2); }
|
||||
|
||||
/* Buttons */
|
||||
.btn { padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; cursor: pointer; min-height: 36px; }
|
||||
.btn {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease, transform 80ms ease;
|
||||
}
|
||||
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
.btn:focus-visible { outline: 2px solid var(--app-primary); outline-offset: 2px; }
|
||||
.btn--sm { padding: var(--space-1) var(--space-3); min-height: 30px; font-size: var(--text-xs); }
|
||||
.btn--primary { background: var(--app-primary); color: var(--color-surface); border: none; }
|
||||
.btn--primary:hover:not(:disabled) { opacity: 0.9; }
|
||||
.btn--primary:hover:not(:disabled) { opacity: 0.88; }
|
||||
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn--ghost { background: none; border: 1px solid var(--color-border); color: var(--color-text); }
|
||||
.btn--ghost:hover { background: var(--color-surface-alt); }
|
||||
.btn--danger { background: var(--app-accent); color: white; border: none; }
|
||||
.btn--danger:hover { opacity: 0.9; }
|
||||
.btn--ghost:hover:not(:disabled) { background: var(--color-surface-alt); border-color: var(--app-primary); color: var(--app-primary); }
|
||||
.btn--danger { background: var(--app-accent); color: var(--app-accent-text); border: none; }
|
||||
.btn--danger:hover:not(:disabled) { opacity: 0.88; }
|
||||
|
||||
/* Spinner inside buttons */
|
||||
.btn__spinner {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid rgba(255,255,255,0.35);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: btn-spin 0.65s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes btn-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Modals (delete confirm) */
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
background: var(--color-overlay);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -442,7 +442,7 @@ onMounted(fetchRefs)
|
|||
.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; }
|
||||
.tag-chip--academic { background: color-mix(in srgb, var(--status-synced) 12%, var(--color-surface)); color: var(--status-synced); }
|
||||
|
||||
.ref-card__actions {
|
||||
display: flex;
|
||||
|
|
|
|||
Loading…
Reference in a new issue