Compare commits

..

10 commits

Author SHA1 Message Date
82eeb4defc fix: prevent blank page on rebuild and queue drain on skip/discard
Two bugs fixed:

1. Blank white page after vue SPA rebuild: browsers cached old index.html
   referencing old asset hashes. Assets are deleted on rebuild, causing
   404s for JS/CSS -> blank page. Fix: serve index.html with
   Cache-Control: no-cache so browsers always fetch fresh HTML.
   Hashed assets (/assets/chunk-abc123.js) remain cacheable forever.

2. Queue draining to empty on skip/discard: handleSkip and handleDiscard
   never refilled the local queue buffer. After enough skips, store.current
   went null and the empty state showed (blank-looking). Fix: both handlers
   now call fetchBatch() when queue drops below 3, matching handleLabel.

Also: sync classifier_adapters LABELS to match current 10-label schema
(new_lead + hired, remove unrelated).

48 Python tests pass, 48 frontend tests pass.
2026-03-03 19:26:34 -08:00
a06b133a6e docs(avocet): document email field schemas and normalization layer 2026-03-03 18:43:41 -08:00
b54b2a711e fix(avocet): normalize queue schema + bind to 0.0.0.0 for LAN access
- Add _item_id() (content hash) + _normalize() to map legacy JSONL fields
  (from_addr/account/no-id) to Vue schema (from/source/id)
- All mutating endpoints now look up by _normalize(x)[id] — handles both
  stored-id (test fixtures) and content-hash (real data) transparently
- Change uvicorn bind from 127.0.0.1 to 0.0.0.0 so LAN clients can connect
2026-03-03 18:43:00 -08:00
cd7bbd1dbf fix(avocet): start-api polls port instead of sleeping 1s — avoids false-success on slow start 2026-03-03 18:11:53 -08:00
682a958c28 fix(avocet): strip HTML from email bodies — stdlib HTMLParser, no deps 2026-03-03 16:28:18 -08:00
47973aeba6 feat(avocet): easter eggs — hired confetti, century mark, clean sweep, midnight labeler, cursor trail 2026-03-03 16:24:47 -08:00
deddd763ea feat(avocet): manage.sh start-api / stop-api / open-api commands 2026-03-03 16:23:56 -08:00
382bca28a1 feat(avocet): LabelView — wires store, API, card stack, keyboard, easter eggs
Implements Task 13: LabelView.vue wires together the label store, API
fetch, card stack, bucket grid, keyboard shortcuts, haptics, motion
preference, and three easter egg badges (on-a-roll, speed round, fifty
deep). App.vue updated to mount LabelView and restore hacker-mode theme
on load. 3 new LabelView tests; all 48 tests pass, build clean.
2026-03-03 16:21:07 -08:00
b623d252d0 feat(avocet): LabelBucketGrid bucket-mode CSS — spring expansion, glow on drop 2026-03-03 16:19:29 -08:00
9b5a1ae752 feat(avocet): EmailCardStack — swipe gestures, depth shadows, dismissal classes 2026-03-03 16:16:09 -08:00
14 changed files with 1307 additions and 97 deletions

View file

@ -75,8 +75,9 @@ conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --exp
| `survey_received` | 5 | Culture-fit survey or assessment invitation |
| `neutral` | 6 | ATS confirmation (application received, etc.) |
| `event_rescheduled` | 7 | Interview or event moved to a new time |
| `unrelated` | 8 | Non-job-search email, not classifiable |
| `digest` | 9 | Job digest or multi-listing email (scrapeable) |
| `digest` | 8 | Job digest or multi-listing email (scrapeable) |
| `new_lead` | 9 | Unsolicited recruiter outreach or cold contact |
| `hired` | h | Offer accepted, onboarding, welcome email, start date |
## Model Registry (13 models, 7 defaults)
@ -105,6 +106,67 @@ Add `--models deberta-small deberta-small-2pass` to test a specific subset.
- `--compare` uses the first account in `label_tool.yaml` for live IMAP emails.
- DB export labels are llama3.1:8b-generated — treat as noisy, not gold truth.
## Vue Label UI (app/api.py + web/)
FastAPI on port 8503 serves both the REST API and the built Vue SPA (`web/dist/`).
```
./manage.sh start-api # build Vue SPA + start FastAPI (binds 0.0.0.0:8503 — LAN accessible)
./manage.sh stop-api
./manage.sh open-api # xdg-open http://localhost:8503
```
Logs: `log/api.log`
## Email Field Schema — IMPORTANT
Two schemas exist. The normalization layer in `app/api.py` bridges them automatically.
### JSONL on-disk schema (written by `label_tool.py` and `label_tool.py`'s IMAP fetch)
| Field | Type | Notes |
|-------|------|-------|
| `subject` | str | Email subject line |
| `body` | str | Plain-text body, truncated at 800 chars; HTML stripped by `_strip_html()` |
| `from_addr` | str | Sender address string (`"Name <addr>"`) |
| `date` | str | Raw RFC 2822 date string |
| `account` | str | Display name of the IMAP account that fetched it |
| *(no `id`)* | — | Dedup key is MD5 of `(subject + body[:100])` — never stored on disk |
### Vue API schema (returned by `GET /api/queue`, required by POST endpoints)
| Field | Type | Notes |
|-------|------|-------|
| `id` | str | MD5 content hash, or stored `id` if item has one |
| `subject` | str | Unchanged |
| `body` | str | Unchanged |
| `from` | str | Mapped from `from_addr` (or `from` if already present) |
| `date` | str | Unchanged |
| `source` | str | Mapped from `account` (or `source` if already present) |
### Normalization layer (`_normalize()` in `app/api.py`)
`_normalize(item)` handles the mapping and ID generation. All `GET /api/queue` responses
pass through it. Mutating endpoints (`/api/label`, `/api/skip`, `/api/discard`) look up
items via `_normalize(x)["id"]`, so both real data (no `id`, uses content hash) and test
fixtures (explicit `id` field) work transparently.
### Peregrine integration
Peregrine's `staging.db` uses different field names again:
| staging.db column | Maps to avocet JSONL field |
|-------------------|---------------------------|
| `subject` | `subject` |
| `body` | `body` (may contain HTML — run through `_strip_html()` before queuing) |
| `from_address` | `from_addr` |
| `received_date` | `date` |
| `account` or source context | `account` |
When exporting from Peregrine's DB for avocet labeling, transform to the JSONL schema above
(not the Vue API schema). The `--export-db` flag in `benchmark_classifier.py` does this.
Any new export path should also call `_strip_html()` on the body before writing.
## Relationship to Peregrine
Avocet started as `peregrine/tools/label_tool.py` + `peregrine/scripts/classifier_adapters.py`.

View file

@ -5,6 +5,7 @@ Endpoints and static file serving are added in subsequent tasks.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
@ -60,6 +61,29 @@ def _append_jsonl(path: Path, record: dict) -> None:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _item_id(item: dict) -> str:
"""Stable content-hash ID — matches label_tool.py _entry_key dedup logic."""
key = (item.get("subject", "") + (item.get("body", "") or "")[:100])
return hashlib.md5(key.encode("utf-8", errors="replace")).hexdigest()
def _normalize(item: dict) -> dict:
"""Normalize JSONL item to the Vue frontend schema.
label_tool.py stores: subject, body, from_addr, date, account (no id).
The Vue app expects: id, subject, body, from, date, source.
Both old (from_addr/account) and new (from/source) field names are handled.
"""
return {
"id": item.get("id") or _item_id(item),
"subject": item.get("subject", ""),
"body": item.get("body", ""),
"from": item.get("from") or item.get("from_addr", ""),
"date": item.get("date", ""),
"source": item.get("source") or item.get("account", ""),
}
app = FastAPI(title="Avocet API")
# In-memory last-action store (single user, local tool — in-memory is fine)
@ -69,7 +93,7 @@ _last_action: dict | None = None
@app.get("/api/queue")
def get_queue(limit: int = Query(default=10, ge=1, le=50)):
items = _read_jsonl(_queue_file())
return {"items": items[:limit], "total": len(items)}
return {"items": [_normalize(x) for x in items[:limit]], "total": len(items)}
class LabelRequest(BaseModel):
@ -81,13 +105,13 @@ class LabelRequest(BaseModel):
def post_label(req: LabelRequest):
global _last_action
items = _read_jsonl(_queue_file())
match = next((x for x in items if x["id"] == req.id), None)
match = next((x for x in items if _normalize(x)["id"] == req.id), None)
if not match:
raise HTTPException(404, f"Item {req.id!r} not found in queue")
record = {**match, "label": req.label,
"labeled_at": datetime.now(timezone.utc).isoformat()}
_append_jsonl(_score_file(), record)
_write_jsonl(_queue_file(), [x for x in items if x["id"] != req.id])
_write_jsonl(_queue_file(), [x for x in items if _normalize(x)["id"] != req.id])
_last_action = {"type": "label", "item": match, "label": req.label}
return {"ok": True}
@ -100,10 +124,10 @@ class SkipRequest(BaseModel):
def post_skip(req: SkipRequest):
global _last_action
items = _read_jsonl(_queue_file())
match = next((x for x in items if x["id"] == req.id), None)
match = next((x for x in items if _normalize(x)["id"] == req.id), None)
if not match:
raise HTTPException(404, f"Item {req.id!r} not found in queue")
reordered = [x for x in items if x["id"] != req.id] + [match]
reordered = [x for x in items if _normalize(x)["id"] != req.id] + [match]
_write_jsonl(_queue_file(), reordered)
_last_action = {"type": "skip", "item": match}
return {"ok": True}
@ -117,14 +141,14 @@ class DiscardRequest(BaseModel):
def post_discard(req: DiscardRequest):
global _last_action
items = _read_jsonl(_queue_file())
match = next((x for x in items if x["id"] == req.id), None)
match = next((x for x in items if _normalize(x)["id"] == req.id), None)
if not match:
raise HTTPException(404, f"Item {req.id!r} not found in queue")
record = {**match, "label": "__discarded__",
"discarded_at": datetime.now(timezone.utc).isoformat()}
_append_jsonl(_discarded_file(), record)
_write_jsonl(_queue_file(), [x for x in items if x["id"] != req.id])
_last_action = {"type": "discard", "item": match} # store ORIGINAL match, not enriched record
_write_jsonl(_queue_file(), [x for x in items if _normalize(x)["id"] != req.id])
_last_action = {"type": "discard", "item": match}
return {"ok": True}
@ -153,14 +177,13 @@ def delete_undo():
_write_jsonl(_queue_file(), [item] + items)
elif action["type"] == "skip":
items = _read_jsonl(_queue_file())
# Remove the item wherever it sits (guards against duplicate insertion),
# then prepend it to the front — restoring it to position 0.
items = [item] + [x for x in items if x["id"] != item["id"]]
item_id = _normalize(item)["id"]
items = [item] + [x for x in items if _normalize(x)["id"] != item_id]
_write_jsonl(_queue_file(), items)
# Clear AFTER all file operations succeed
_last_action = None
return {"undone": {"type": action["type"], "item": item}}
return {"undone": {"type": action["type"], "item": _normalize(item)}}
# Label metadata — 10 labels matching label_tool.py
@ -186,5 +209,16 @@ def get_labels():
# Static SPA — MUST be last (catches all unmatched paths)
_DIST = _ROOT / "web" / "dist"
if _DIST.exists():
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
# Serve index.html with no-cache so browsers always fetch fresh HTML after rebuilds.
# Hashed assets (/assets/index-abc123.js) can be cached forever — they change names
# when content changes (standard Vite cache-busting strategy).
_NO_CACHE = {"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache"}
@app.get("/")
def get_spa_root():
return FileResponse(str(_DIST / "index.html"), headers=_NO_CACHE)
app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="spa")

View file

@ -14,6 +14,7 @@ from __future__ import annotations
import email as _email_lib
import hashlib
import html as _html
from html.parser import HTMLParser
import imaplib
import json
import re
@ -23,6 +24,9 @@ from email.header import decode_header as _raw_decode
from pathlib import Path
from typing import Any
import os
import subprocess
import streamlit as st
import yaml
@ -43,8 +47,9 @@ LABELS = [
"survey_received",
"neutral",
"event_rescheduled",
"unrelated",
"digest",
"new_lead",
"hired",
]
_LABEL_META: dict[str, dict] = {
@ -55,8 +60,9 @@ _LABEL_META: dict[str, dict] = {
"survey_received": {"emoji": "📋", "color": "#9C27B0", "key": "5"},
"neutral": {"emoji": "", "color": "#607D8B", "key": "6"},
"event_rescheduled": {"emoji": "🔄", "color": "#FF5722", "key": "7"},
"unrelated": {"emoji": "🗑️", "color": "#757575", "key": "8"},
"digest": {"emoji": "📰", "color": "#00BCD4", "key": "9"},
"digest": {"emoji": "📰", "color": "#00BCD4", "key": "8"},
"new_lead": {"emoji": "🤝", "color": "#009688", "key": "9"},
"hired": {"emoji": "🎊", "color": "#FFC107", "key": "h"},
}
# ── HTML sanitiser ───────────────────────────────────────────────────────────
@ -78,7 +84,50 @@ def _to_html(text: str, newlines_to_br: bool = False) -> str:
return escaped
# ── Wide IMAP search terms (cast a net across all 9 categories) ─────────────
# ── HTML → plain-text extractor ─────────────────────────────────────────────
class _TextExtractor(HTMLParser):
"""Extract visible text from an HTML email body, preserving line breaks."""
_BLOCK = {"p","div","br","li","tr","h1","h2","h3","h4","h5","h6","blockquote"}
_SKIP = {"script","style","head","noscript"}
def __init__(self):
super().__init__(convert_charrefs=True)
self._parts: list[str] = []
self._depth_skip = 0
def handle_starttag(self, tag, attrs):
tag = tag.lower()
if tag in self._SKIP:
self._depth_skip += 1
elif tag in self._BLOCK:
self._parts.append("\n")
def handle_endtag(self, tag):
if tag.lower() in self._SKIP:
self._depth_skip = max(0, self._depth_skip - 1)
def handle_data(self, data):
if not self._depth_skip:
self._parts.append(data)
def get_text(self) -> str:
text = "".join(self._parts)
lines = [ln.strip() for ln in text.splitlines()]
return "\n".join(ln for ln in lines if ln)
def _strip_html(html_str: str) -> str:
"""Convert HTML email body to plain text. Pure stdlib, no dependencies."""
try:
extractor = _TextExtractor()
extractor.feed(html_str)
return extractor.get_text()
except Exception:
return re.sub(r"<[^>]+>", " ", html_str).strip()
# ── Wide IMAP search terms (cast a net across all 10 categories) ────────────
_WIDE_TERMS = [
# interview_scheduled
"interview", "phone screen", "video call", "zoom link", "schedule a call",
@ -100,6 +149,11 @@ _WIDE_TERMS = [
# digest
"job digest", "jobs you may like", "recommended jobs", "jobs for you",
"new jobs", "job alert",
# new_lead
"came across your profile", "reaching out about", "great fit for a role",
"exciting opportunity", "love to connect",
# hired / onboarding
"welcome to the team", "start date", "onboarding", "first day", "we're excited to have you",
# general recruitment
"application", "recruiter", "recruiting", "hiring", "candidate",
]
@ -121,18 +175,32 @@ def _decode_str(value: str | None) -> str:
def _extract_body(msg: Any) -> str:
"""Return plain-text body. Strips HTML when no text/plain part exists."""
if msg.is_multipart():
html_fallback: str | None = None
for part in msg.walk():
if part.get_content_type() == "text/plain":
ct = part.get_content_type()
if ct == "text/plain":
try:
charset = part.get_content_charset() or "utf-8"
return part.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
elif ct == "text/html" and html_fallback is None:
try:
charset = part.get_content_charset() or "utf-8"
raw = part.get_payload(decode=True).decode(charset, errors="replace")
html_fallback = _strip_html(raw)
except Exception:
pass
return html_fallback or ""
else:
try:
charset = msg.get_content_charset() or "utf-8"
return msg.get_payload(decode=True).decode(charset, errors="replace")
raw = msg.get_payload(decode=True).decode(charset, errors="replace")
if msg.get_content_type() == "text/html":
return _strip_html(raw)
return raw
except Exception:
pass
return ""
@ -436,7 +504,9 @@ with st.sidebar:
# ── Tabs ─────────────────────────────────────────────────────────────────────
tab_label, tab_fetch, tab_stats, tab_settings = st.tabs(["🃏 Label", "📥 Fetch", "📊 Stats", "⚙️ Settings"])
tab_label, tab_fetch, tab_stats, tab_settings, tab_benchmark = st.tabs(
["🃏 Label", "📥 Fetch", "📊 Stats", "⚙️ Settings", "🔬 Benchmark"]
)
# ══════════════════════════════════════════════════════════════════════════════
@ -669,19 +739,19 @@ with tab_label:
_lbl_r = _r.get("label", "")
_counts[_lbl_r] = _counts.get(_lbl_r, 0) + 1
row1_cols = st.columns(3)
row2_cols = st.columns(3)
row3_cols = st.columns(3)
row1_cols = st.columns(5)
row2_cols = st.columns(5)
bucket_pairs = [
(row1_cols[0], "interview_scheduled"),
(row1_cols[1], "offer_received"),
(row1_cols[2], "rejected"),
(row2_cols[0], "positive_response"),
(row2_cols[1], "survey_received"),
(row2_cols[2], "neutral"),
(row3_cols[0], "event_rescheduled"),
(row3_cols[1], "unrelated"),
(row3_cols[2], "digest"),
(row1_cols[3], "positive_response"),
(row1_cols[4], "survey_received"),
(row2_cols[0], "neutral"),
(row2_cols[1], "event_rescheduled"),
(row2_cols[2], "digest"),
(row2_cols[3], "new_lead"),
(row2_cols[4], "hired"),
]
for col, lbl in bucket_pairs:
m = _LABEL_META[lbl]
@ -720,7 +790,7 @@ with tab_label:
nav_cols = st.columns([2, 1, 1, 1])
remaining = len(unlabeled) - 1
nav_cols[0].caption(f"**{remaining}** remaining · Keys: 19 = label, 0 = other, S = skip, U = undo")
nav_cols[0].caption(f"**{remaining}** remaining · Keys: 19, H = label, 0 = other, S = skip, U = undo")
if nav_cols[1].button("↩ Undo", disabled=not st.session_state.history, use_container_width=True):
prev_idx, prev_label = st.session_state.history.pop()
@ -757,7 +827,7 @@ document.addEventListener('keydown', function(e) {
const keyToLabel = {
'1':'interview_scheduled','2':'offer_received','3':'rejected',
'4':'positive_response','5':'survey_received','6':'neutral',
'7':'event_rescheduled','8':'unrelated','9':'digest'
'7':'event_rescheduled','8':'digest','9':'new_lead'
};
const label = keyToLabel[e.key];
if (label) {
@ -772,6 +842,11 @@ document.addEventListener('keydown', function(e) {
for (const btn of btns) {
if (btn.innerText.includes('Other')) { btn.click(); break; }
}
} else if (e.key.toLowerCase() === 'h') {
const btns = window.parent.document.querySelectorAll('button');
for (const btn of btns) {
if (btn.innerText.toLowerCase().includes('hired')) { btn.click(); break; }
}
} else if (e.key.toLowerCase() === 's') {
const btns = window.parent.document.querySelectorAll('button');
for (const btn of btns) {
@ -979,3 +1054,133 @@ with tab_settings:
if _k in ("settings_accounts", "settings_max") or _k.startswith("s_"):
del st.session_state[_k]
st.rerun()
# ══════════════════════════════════════════════════════════════════════════════
# BENCHMARK TAB
# ══════════════════════════════════════════════════════════════════════════════
with tab_benchmark:
# ── Model selection ───────────────────────────────────────────────────────
_DEFAULT_MODELS = [
"deberta-zeroshot", "deberta-small", "gliclass-large",
"bart-mnli", "bge-m3-zeroshot", "deberta-small-2pass", "deberta-base-anli",
]
_SLOW_MODELS = [
"deberta-large-ling", "mdeberta-xnli-2m", "bge-reranker",
"deberta-xlarge", "mdeberta-mnli", "xlm-roberta-anli",
]
st.subheader("🔬 Benchmark Classifier Models")
_b_include_slow = st.checkbox("Include slow / large models", value=False, key="b_include_slow")
_b_all_models = _DEFAULT_MODELS + (_SLOW_MODELS if _b_include_slow else [])
_b_selected = st.multiselect(
"Models to run",
options=_b_all_models,
default=_b_all_models,
help="Uncheck models to skip them. Slow models require --include-slow.",
)
_n_examples = len(st.session_state.labeled)
st.caption(
f"Scoring against `{_SCORE_FILE.name}` · **{_n_examples} labeled examples**"
f" · Est. time: ~{max(1, len(_b_selected))} {max(2, len(_b_selected) * 2)} min"
)
# Direct binary avoids conda's output interception; -u = unbuffered stdout
_CLASSIFIER_PYTHON = "/devl/miniconda3/envs/job-seeker-classifiers/bin/python"
if st.button("▶ Run Benchmark", type="primary", disabled=not _b_selected, key="b_run"):
_b_cmd = [
_CLASSIFIER_PYTHON, "-u",
str(_ROOT / "scripts" / "benchmark_classifier.py"),
"--score", "--score-file", str(_SCORE_FILE),
"--models", *_b_selected,
]
with st.status("Running benchmark…", expanded=True) as _b_status:
_b_proc = subprocess.Popen(
_b_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=str(_ROOT),
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
_b_lines: list[str] = []
_b_area = st.empty()
for _b_line in _b_proc.stdout:
_b_lines.append(_b_line)
_b_area.code("".join(_b_lines[-30:]), language="text")
_b_proc.wait()
_b_full = "".join(_b_lines)
st.session_state["bench_output"] = _b_full
if _b_proc.returncode == 0:
_b_status.update(label="Benchmark complete ✓", state="complete", expanded=False)
else:
_b_status.update(label="Benchmark failed", state="error")
# ── Results display ───────────────────────────────────────────────────────
if "bench_output" in st.session_state:
_b_out = st.session_state["bench_output"]
# Parse summary table rows: name f1 accuracy ms
_b_rows = []
for _b_l in _b_out.splitlines():
_b_m = re.match(r"^([\w-]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*$", _b_l.strip())
if _b_m:
_b_rows.append({
"Model": _b_m.group(1),
"macro-F1": float(_b_m.group(2)),
"Accuracy": float(_b_m.group(3)),
"ms/email": float(_b_m.group(4)),
})
if _b_rows:
import pandas as _pd
_b_df = _pd.DataFrame(_b_rows).sort_values("macro-F1", ascending=False).reset_index(drop=True)
st.dataframe(
_b_df,
column_config={
"macro-F1": st.column_config.ProgressColumn(
"macro-F1", min_value=0, max_value=1, format="%.3f",
),
"Accuracy": st.column_config.ProgressColumn(
"Accuracy", min_value=0, max_value=1, format="%.3f",
),
"ms/email": st.column_config.NumberColumn("ms/email", format="%.1f"),
},
use_container_width=True, hide_index=True,
)
with st.expander("Full benchmark output"):
st.code(_b_out, language="text")
st.divider()
# ── Tests ─────────────────────────────────────────────────────────────────
st.subheader("🧪 Run Tests")
st.caption("Runs `pytest tests/ -v` in the job-seeker env (no model downloads required).")
if st.button("▶ Run Tests", key="b_run_tests"):
_t_cmd = [
"/devl/miniconda3/envs/job-seeker/bin/pytest", "tests/", "-v", "--tb=short",
]
with st.status("Running tests…", expanded=True) as _t_status:
_t_proc = subprocess.Popen(
_t_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=str(_ROOT),
)
_t_lines: list[str] = []
_t_area = st.empty()
for _t_line in _t_proc.stdout:
_t_lines.append(_t_line)
_t_area.code("".join(_t_lines[-30:]), language="text")
_t_proc.wait()
_t_full = "".join(_t_lines)
st.session_state["test_output"] = _t_full
_t_summary = [l for l in _t_lines if "passed" in l or "failed" in l or "error" in l.lower()]
_t_label = _t_summary[-1].strip() if _t_summary else "Done"
_t_state = "error" if _t_proc.returncode != 0 else "complete"
_t_status.update(label=_t_label, state=_t_state, expanded=False)
if "test_output" in st.session_state:
with st.expander("Full test output", expanded=True):
st.code(st.session_state["test_output"], language="text")

View file

@ -93,6 +93,11 @@ usage() {
echo -e " ${GREEN}score [args]${NC} Shortcut: --score [args]"
echo -e " ${GREEN}compare [args]${NC} Shortcut: --compare [args]"
echo ""
echo " Vue API:"
echo -e " ${GREEN}start-api${NC} Build Vue SPA + start FastAPI on port 8503"
echo -e " ${GREEN}stop-api${NC} Stop FastAPI server"
echo -e " ${GREEN}open-api${NC} Open Vue UI in browser (http://localhost:8503)"
echo ""
echo " Dev:"
echo -e " ${GREEN}test${NC} Run pytest suite"
echo ""
@ -251,6 +256,67 @@ case "$CMD" in
exec "$0" benchmark --compare "$@"
;;
start-api)
API_PID_FILE=".avocet-api.pid"
API_PORT=8503
if [[ -f "$API_PID_FILE" ]] && kill -0 "$(<"$API_PID_FILE")" 2>/dev/null; then
warn "API already running (PID $(<"$API_PID_FILE")) → http://localhost:${API_PORT}"
exit 0
fi
mkdir -p "$LOG_DIR"
API_LOG="${LOG_DIR}/api.log"
info "Building Vue SPA…"
(cd web && npm run build) >> "$API_LOG" 2>&1
info "Starting FastAPI on port ${API_PORT}"
nohup "$PYTHON_UI" -m uvicorn app.api:app \
--host 0.0.0.0 --port "$API_PORT" \
>> "$API_LOG" 2>&1 &
echo $! > "$API_PID_FILE"
# Poll until port is actually bound (up to 10 s), not just process alive
for _i in $(seq 1 20); do
sleep 0.5
if (echo "" >/dev/tcp/127.0.0.1/"$API_PORT") 2>/dev/null; then
success "Avocet API started → http://localhost:${API_PORT} (PID $(<"$API_PID_FILE"))"
break
fi
if ! kill -0 "$(<"$API_PID_FILE")" 2>/dev/null; then
rm -f "$API_PID_FILE"
error "API died during startup. Check ${API_LOG}"
fi
done
if ! (echo "" >/dev/tcp/127.0.0.1/"$API_PORT") 2>/dev/null; then
error "API did not bind to port ${API_PORT} within 10 s. Check ${API_LOG}"
fi
;;
stop-api)
API_PID_FILE=".avocet-api.pid"
if [[ ! -f "$API_PID_FILE" ]]; then
warn "API not running."
exit 0
fi
PID="$(<"$API_PID_FILE")"
if kill -0 "$PID" 2>/dev/null; then
kill "$PID" && rm -f "$API_PID_FILE"
success "API stopped (PID ${PID})."
else
warn "Stale PID file (process ${PID} not running). Cleaning up."
rm -f "$API_PID_FILE"
fi
;;
open-api)
URL="http://localhost:8503"
info "Opening ${URL}"
if command -v xdg-open &>/dev/null; then
xdg-open "$URL"
elif command -v open &>/dev/null; then
open "$URL"
else
echo "$URL"
fi
;;
help|--help|-h)
usage
;;

View file

@ -27,8 +27,9 @@ LABELS: list[str] = [
"survey_received",
"neutral",
"event_rescheduled",
"unrelated",
"digest",
"new_lead",
"hired",
]
# Natural-language descriptions used by the RerankerAdapter.
@ -40,8 +41,9 @@ LABEL_DESCRIPTIONS: dict[str, str] = {
"survey_received": "invitation to complete a culture-fit survey or assessment",
"neutral": "automated ATS confirmation such as application received",
"event_rescheduled": "an interview or scheduled event moved to a new time",
"unrelated": "non-job-search email unrelated to any application or recruiter",
"digest": "job digest or multi-listing email with multiple job postings",
"new_lead": "unsolicited recruiter outreach or cold contact about a new opportunity",
"hired": "job offer accepted, onboarding logistics, welcome email, or start date confirmation",
}
# Lazy import shims — allow tests to patch without requiring the libs installed.

View file

@ -2,14 +2,16 @@
import pytest
def test_labels_constant_has_nine_items():
def test_labels_constant_has_ten_items():
from scripts.classifier_adapters import LABELS
assert len(LABELS) == 9
assert len(LABELS) == 10
assert "interview_scheduled" in LABELS
assert "neutral" in LABELS
assert "event_rescheduled" in LABELS
assert "unrelated" in LABELS
assert "digest" in LABELS
assert "new_lead" in LABELS
assert "hired" in LABELS
assert "unrelated" not in LABELS
def test_compute_metrics_perfect_predictions():

87
tests/test_label_tool.py Normal file
View file

@ -0,0 +1,87 @@
"""Tests for label_tool HTML extraction utilities.
These functions are stdlib-only and safe to test without an IMAP connection.
"""
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from app.label_tool import _extract_body, _strip_html
# ── _strip_html ──────────────────────────────────────────────────────────────
def test_strip_html_removes_tags():
assert _strip_html("<p>Hello <b>world</b></p>") == "Hello world"
def test_strip_html_skips_script_content():
result = _strip_html("<script>doEvil()</script><p>real</p>")
assert "doEvil" not in result
assert "real" in result
def test_strip_html_skips_style_content():
result = _strip_html("<style>.foo{color:red}</style><p>visible</p>")
assert ".foo" not in result
assert "visible" in result
def test_strip_html_handles_br_as_newline():
result = _strip_html("line1<br>line2")
assert "line1" in result
assert "line2" in result
def test_strip_html_decodes_entities():
# convert_charrefs=True on HTMLParser handles &amp; etc.
result = _strip_html("<p>Hello &amp; welcome</p>")
assert "&amp;" not in result
assert "Hello" in result
assert "welcome" in result
def test_strip_html_empty_string():
assert _strip_html("") == ""
def test_strip_html_plain_text_passthrough():
assert _strip_html("no tags here") == "no tags here"
# ── _extract_body ────────────────────────────────────────────────────────────
def test_extract_body_prefers_plain_over_html():
msg = MIMEMultipart("alternative")
msg.attach(MIMEText("plain body", "plain"))
msg.attach(MIMEText("<html><body>html body</body></html>", "html"))
assert _extract_body(msg) == "plain body"
def test_extract_body_falls_back_to_html_when_no_plain():
msg = MIMEMultipart("alternative")
msg.attach(MIMEText("<html><body><p>HTML only email</p></body></html>", "html"))
result = _extract_body(msg)
assert "HTML only email" in result
assert "<" not in result # no raw HTML tags leaked through
def test_extract_body_non_multipart_html_stripped():
msg = MIMEText("<html><body><p>Solo HTML</p></body></html>", "html")
result = _extract_body(msg)
assert "Solo HTML" in result
assert "<html>" not in result
def test_extract_body_non_multipart_plain_unchanged():
msg = MIMEText("just plain text", "plain")
assert _extract_body(msg) == "just plain text"
def test_extract_body_empty_message():
msg = MIMEText("", "plain")
assert _extract_body(msg) == ""
def test_extract_body_multipart_empty_returns_empty():
msg = MIMEMultipart("alternative")
assert _extract_body(msg) == ""

View file

@ -1,30 +1,39 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<div>
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
<div id="app" :class="{ 'rich-motion': motion.rich.value }">
<LabelView />
</div>
<HelloWorld msg="Vite + Vue" />
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
<script setup lang="ts">
import { onMounted } from 'vue'
import { useMotion } from './composables/useMotion'
import { useHackerMode } from './composables/useEasterEgg'
import LabelView from './views/LabelView.vue'
const motion = useMotion()
const { restore } = useHackerMode()
onMounted(() => {
restore() // re-apply hacker mode from localStorage on page load
})
</script>
<style>
/* Global reset + base */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
body {
font-family: var(--font-body, sans-serif);
background: var(--color-bg, #f0f4fc);
color: var(--color-text, #1a2338);
min-height: 100dvh;
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
#app {
min-height: 100dvh;
}
</style>

View file

@ -0,0 +1,59 @@
import { mount } from '@vue/test-utils'
import EmailCardStack from './EmailCardStack.vue'
import { describe, it, expect } from 'vitest'
const item = {
id: 'abc',
subject: 'Interview at Acme',
body: 'We would like to schedule...',
from: 'hr@acme.com',
date: '2026-03-01',
source: 'imap:test',
}
describe('EmailCardStack', () => {
it('renders the email subject', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
expect(w.text()).toContain('Interview at Acme')
})
it('renders shadow cards for depth effect', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
expect(w.findAll('.card-shadow')).toHaveLength(2)
})
it('applies dismiss-label class when dismissType is label', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: 'label' } })
expect(w.find('.card-wrapper').classes()).toContain('dismiss-label')
})
it('applies dismiss-discard class when dismissType is discard', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: 'discard' } })
expect(w.find('.card-wrapper').classes()).toContain('dismiss-discard')
})
it('applies dismiss-skip class when dismissType is skip', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: 'skip' } })
expect(w.find('.card-wrapper').classes()).toContain('dismiss-skip')
})
it('no dismiss class when dismissType is null', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: null } })
const wrapperClasses = w.find('.card-wrapper').classes()
expect(wrapperClasses).not.toContain('dismiss-label')
expect(wrapperClasses).not.toContain('dismiss-discard')
expect(wrapperClasses).not.toContain('dismiss-skip')
})
it('emits drag-start on dragstart event', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
await w.find('.card-stack').trigger('dragstart')
expect(w.emitted('drag-start')).toBeTruthy()
})
it('emits drag-end on dragend event', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
await w.find('.card-stack').trigger('dragend')
expect(w.emitted('drag-end')).toBeTruthy()
})
})

View file

@ -0,0 +1,138 @@
<template>
<div
class="card-stack"
ref="stackEl"
:draggable="motion.rich.value"
@dragstart="onDragStart"
@dragend="onDragEnd"
>
<!-- Depth shadow cards (visual stack effect) -->
<div class="card-shadow card-shadow-2" aria-hidden="true" />
<div class="card-shadow card-shadow-1" aria-hidden="true" />
<!-- Active card -->
<div
class="card-wrapper"
ref="cardEl"
:class="dismissClass"
:style="cardStyle"
>
<EmailCard
:item="item"
:expanded="isExpanded"
@expand="isExpanded = true"
@collapse="isExpanded = false"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useSwipe } from '@vueuse/core'
import { useMotion } from '../composables/useMotion'
import EmailCard from './EmailCard.vue'
import type { QueueItem } from '../stores/label'
const props = defineProps<{
item: QueueItem
isBucketMode: boolean
dismissType?: 'label' | 'skip' | 'discard' | null
}>()
const emit = defineEmits<{
label: [name: string]
skip: []
discard: []
'drag-start': []
'drag-end': []
}>()
const motion = useMotion()
const cardEl = ref<HTMLElement | null>(null)
const stackEl = ref<HTMLElement | null>(null)
const isExpanded = ref(false)
const dragX = ref(0)
const { isSwiping, lengthX } = useSwipe(cardEl, {
threshold: 60,
onSwipeEnd(_, dir) {
if (dir === 'left') emit('discard')
if (dir === 'right') emit('skip')
dragX.value = 0
},
onSwipe() {
if (motion.rich.value) dragX.value = lengthX.value * -1
},
})
const dismissClass = computed(() => {
if (!props.dismissType) return null
return `dismiss-${props.dismissType}`
})
const cardStyle = computed(() => {
if (!motion.rich.value || !isSwiping.value) return {}
const tilt = dragX.value * 0.05
const opacity = Math.abs(dragX.value) > 20 ? 0.9 : 1
const color = dragX.value < -20 ? 'rgba(244,67,54,0.15)'
: dragX.value > 20 ? 'rgba(255,152,0,0.15)'
: 'transparent'
return {
transform: `translateX(${dragX.value}px) rotate(${tilt}deg)`,
opacity,
background: color,
transition: isSwiping.value ? 'none' : 'all 0.3s ease',
}
})
function onDragStart() { emit('drag-start') }
function onDragEnd() { emit('drag-end') }
</script>
<style scoped>
.card-stack {
position: relative;
min-height: 200px;
}
.card-shadow {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
border-radius: var(--radius-card, 1rem);
background: var(--color-surface-raised, #fff);
border: 1px solid var(--color-border, #e0e4ed);
}
.card-shadow-1 { transform: translateY(8px) scale(0.97); opacity: 0.6; }
.card-shadow-2 { transform: translateY(16px) scale(0.94); opacity: 0.35; }
.card-wrapper {
position: relative;
z-index: 1;
border-radius: var(--radius-card, 1rem);
background: var(--color-surface-raised, #fff);
will-change: transform, opacity;
}
/* Dismissal animations — only active under .rich-motion on root */
:global(.rich-motion) .card-wrapper.dismiss-label {
animation: fileAway var(--card-dismiss, 350ms ease-in) forwards;
}
:global(.rich-motion) .card-wrapper.dismiss-discard {
animation: crumple var(--card-dismiss, 350ms ease-in) forwards;
}
:global(.rich-motion) .card-wrapper.dismiss-skip {
animation: slideUnder var(--card-skip, 300ms ease-out) forwards;
}
@keyframes fileAway {
to { transform: translateY(-120%) scale(0.85); opacity: 0; }
}
@keyframes crumple {
50% { transform: scale(0.95) rotate(2deg); filter: brightness(0.6) sepia(1) hue-rotate(-20deg); }
to { transform: scale(0) rotate(8deg); opacity: 0; }
}
@keyframes slideUnder {
to { transform: translateX(110%) rotate(5deg); opacity: 0; }
}
</style>

View file

@ -40,11 +40,11 @@ function onDrop(name: string) {
.label-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--space-2);
transition: all var(--bucket-expand);
gap: 0.5rem;
transition: all var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1));
}
/* Mobile: 3×3 numpad layout + hired at bottom */
/* Mobile: 3-column numpad layout */
@media (max-width: 480px) {
.label-grid {
grid-template-columns: repeat(3, 1fr);
@ -52,67 +52,70 @@ function onDrop(name: string) {
}
.label-grid.bucket-mode {
gap: var(--space-4);
padding: var(--space-3);
gap: 1rem;
padding: 1rem;
}
.label-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-1);
border: 2px solid var(--label-color, var(--color-border));
border-radius: var(--radius-md);
justify-content: center;
gap: 0.25rem;
min-height: 44px; /* Touch target */
padding: 0.5rem 0.25rem;
border-radius: 0.5rem;
border: 2px solid var(--label-color, #607D8B);
background: transparent;
color: var(--color-text);
color: var(--color-text, #1a2338);
cursor: pointer;
font-family: var(--font-body);
transition: all var(--bucket-expand);
min-height: 44px; /* touch target */
transition: all var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1));
font-family: var(--font-body, sans-serif);
}
.label-grid.bucket-mode .label-btn {
padding: var(--space-6) var(--space-2);
font-size: 1.15rem;
}
.label-btn:hover,
.label-btn:focus-visible {
background: color-mix(in srgb, var(--label-color) 12%, transparent);
}
.label-btn:focus-visible {
outline: 2px solid var(--label-color);
outline-offset: 2px;
min-height: 80px;
padding: 1rem 0.5rem;
border-width: 3px;
font-size: 1.1rem;
}
.label-btn.is-drop-target {
background: var(--label-color);
color: var(--color-text-inverse, #fff);
background: var(--label-color, #607D8B);
color: #fff;
transform: scale(1.08);
box-shadow: 0 0 16px color-mix(in srgb, var(--label-color) 60%, transparent);
box-shadow: 0 0 16px color-mix(in srgb, var(--label-color, #607D8B) 60%, transparent);
}
.label-btn:hover:not(.is-drop-target) {
background: color-mix(in srgb, var(--label-color, #607D8B) 12%, transparent);
}
.key-hint {
font-size: 0.7rem;
font-weight: 700;
opacity: 0.6;
font-family: var(--font-mono);
font-size: 0.65rem;
font-family: var(--font-mono, monospace);
opacity: 0.55;
line-height: 1;
}
.emoji { font-size: 1.2rem; line-height: 1; }
.emoji {
font-size: 1.25rem;
line-height: 1;
}
.label-name {
font-size: 0.65rem;
font-size: 0.7rem;
text-align: center;
line-height: 1.2;
word-break: break-word;
color: var(--color-text-muted);
hyphens: auto;
}
.label-grid.bucket-mode .label-name {
font-size: 0.75rem;
color: var(--color-text);
/* Reduced-motion fallback */
@media (prefers-reduced-motion: reduce) {
.label-grid,
.label-btn {
transition: none;
}
}
</style>

View file

@ -41,3 +41,114 @@ export function useHackerMode() {
return { toggle, restore }
}
/** Fire a confetti burst from a given x,y position. Pure canvas, no dependencies. */
export function fireConfetti(originX = window.innerWidth / 2, originY = window.innerHeight / 2) {
if (typeof requestAnimationFrame === 'undefined') return
const canvas = document.createElement('canvas')
canvas.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999;'
canvas.width = window.innerWidth
canvas.height = window.innerHeight
document.body.appendChild(canvas)
const ctx = canvas.getContext('2d')!
const COLORS = ['#2A6080','#B8622A','#5A9DBF','#D4854A','#FFC107','#4CAF50']
const particles = Array.from({ length: 80 }, () => ({
x: originX,
y: originY,
vx: (Math.random() - 0.5) * 14,
vy: (Math.random() - 0.6) * 12,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
size: 5 + Math.random() * 6,
angle: Math.random() * Math.PI * 2,
spin: (Math.random() - 0.5) * 0.3,
life: 1.0,
}))
let raf = 0
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
let alive = false
for (const p of particles) {
p.x += p.vx
p.y += p.vy
p.vy += 0.35 // gravity
p.vx *= 0.98 // air friction
p.angle += p.spin
p.life -= 0.016
if (p.life <= 0) continue
alive = true
ctx.save()
ctx.globalAlpha = p.life
ctx.fillStyle = p.color
ctx.translate(p.x, p.y)
ctx.rotate(p.angle)
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6)
ctx.restore()
}
if (alive) {
raf = requestAnimationFrame(draw)
} else {
cancelAnimationFrame(raf)
canvas.remove()
}
}
raf = requestAnimationFrame(draw)
}
/** Enable cursor trail in hacker mode — returns a cleanup function. */
export function useCursorTrail() {
const DOT_COUNT = 10
const dots: HTMLElement[] = []
let positions: { x: number; y: number }[] = []
let mouseX = 0
let mouseY = 0
let raf = 0
for (let i = 0; i < DOT_COUNT; i++) {
const dot = document.createElement('div')
dot.style.cssText = [
'position:fixed',
'pointer-events:none',
'z-index:9998',
'border-radius:50%',
'background:#5A9DBF',
'transition:opacity 0.1s',
].join(';')
document.body.appendChild(dot)
dots.push(dot)
}
function onMouseMove(e: MouseEvent) {
mouseX = e.clientX
mouseY = e.clientY
}
function animate() {
positions.unshift({ x: mouseX, y: mouseY })
if (positions.length > DOT_COUNT) positions = positions.slice(0, DOT_COUNT)
dots.forEach((dot, i) => {
const pos = positions[i]
if (!pos) { dot.style.opacity = '0'; return }
const scale = 1 - i / DOT_COUNT
const size = Math.round(8 * scale)
dot.style.left = `${pos.x - size / 2}px`
dot.style.top = `${pos.y - size / 2}px`
dot.style.width = `${size}px`
dot.style.height = `${size}px`
dot.style.opacity = `${(1 - i / DOT_COUNT) * 0.7}`
})
raf = requestAnimationFrame(animate)
}
window.addEventListener('mousemove', onMouseMove)
raf = requestAnimationFrame(animate)
return function cleanup() {
window.removeEventListener('mousemove', onMouseMove)
cancelAnimationFrame(raf)
dots.forEach(d => d.remove())
}
}

View file

@ -0,0 +1,46 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import LabelView from './LabelView.vue'
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock fetch globally
beforeEach(() => {
setActivePinia(createPinia())
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ items: [], total: 0 }),
text: async () => '',
}))
})
describe('LabelView', () => {
it('shows loading state initially', () => {
const w = mount(LabelView, {
global: { plugins: [createPinia()] },
})
// Should show skeleton while loading
expect(w.find('.skeleton-card').exists()).toBe(true)
})
it('shows empty state when queue is empty after load', async () => {
const w = mount(LabelView, {
global: { plugins: [createPinia()] },
})
// Let all promises resolve
await new Promise(r => setTimeout(r, 0))
await w.vm.$nextTick()
expect(w.find('.empty-state').exists()).toBe(true)
})
it('renders header with action buttons', async () => {
const w = mount(LabelView, {
global: { plugins: [createPinia()] },
})
await new Promise(r => setTimeout(r, 0))
await w.vm.$nextTick()
expect(w.find('.lv-header').exists()).toBe(true)
expect(w.text()).toContain('Undo')
expect(w.text()).toContain('Skip')
expect(w.text()).toContain('Discard')
})
})

386
web/src/views/LabelView.vue Normal file
View file

@ -0,0 +1,386 @@
<template>
<div class="label-view">
<!-- Header bar -->
<header class="lv-header">
<span class="queue-count">
<template v-if="store.totalRemaining > 0">
{{ store.totalRemaining }} remaining
</template>
<span v-if="onRoll" class="badge badge-roll">🔥 On a roll!</span>
<span v-if="speedRound" class="badge badge-speed"> Speed round!</span>
<span v-if="fiftyDeep" class="badge badge-fifty">🎯 Fifty deep!</span>
<span v-if="centuryMark" class="badge badge-century">💯 Century!</span>
<span v-if="cleanSweep" class="badge badge-sweep">🧹 Clean sweep!</span>
<span v-if="midnightLabeler" class="badge badge-midnight">🦉 Midnight labeler!</span>
</span>
<div class="header-actions">
<button @click="handleUndo" :disabled="!store.lastAction" class="btn-action"> Undo</button>
<button @click="handleSkip" :disabled="!store.current" class="btn-action"> Skip</button>
<button @click="handleDiscard" :disabled="!store.current" class="btn-action btn-danger"> Discard</button>
</div>
</header>
<!-- States -->
<div v-if="loading" class="skeleton-card" aria-label="Loading email" />
<div v-else-if="apiError" class="error-display" role="alert">
<p>Couldn't reach Avocet API.</p>
<button @click="fetchBatch" class="btn-action">Retry</button>
</div>
<div v-else-if="!store.current" class="empty-state">
<p>Queue is empty fetch more emails to continue.</p>
</div>
<!-- Card stack + label grid -->
<template v-else>
<div class="card-stack-wrapper">
<EmailCardStack
:item="store.current"
:is-bucket-mode="isDragging"
:dismiss-type="dismissType"
@label="handleLabel"
@skip="handleSkip"
@discard="handleDiscard"
@drag-start="isDragging = true"
@drag-end="isDragging = false"
/>
</div>
<LabelBucketGrid
:labels="labels"
:is-bucket-mode="isDragging"
@label="handleLabel"
/>
</template>
<!-- Undo toast -->
<UndoToast
v-if="store.lastAction"
:action="store.lastAction"
@undo="handleUndo"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useLabelStore } from '../stores/label'
import { useApiFetch } from '../composables/useApi'
import { useHaptics } from '../composables/useHaptics'
import { useMotion } from '../composables/useMotion'
import { useLabelKeyboard } from '../composables/useLabelKeyboard'
import { fireConfetti, useCursorTrail } from '../composables/useEasterEgg'
import EmailCardStack from '../components/EmailCardStack.vue'
import LabelBucketGrid from '../components/LabelBucketGrid.vue'
import UndoToast from '../components/UndoToast.vue'
const store = useLabelStore()
const haptics = useHaptics()
const motion = useMotion() // only needed to pass to child actual value used in App.vue
const loading = ref(true)
const apiError = ref(false)
const isDragging = ref(false)
const labels = ref<any[]>([])
const dismissType = ref<'label' | 'skip' | 'discard' | null>(null)
// Easter egg state
const consecutiveLabeled = ref(0)
const recentLabels = ref<number[]>([])
const onRoll = ref(false)
const speedRound = ref(false)
const fiftyDeep = ref(false)
// New easter egg state
const centuryMark = ref(false)
const cleanSweep = ref(false)
const midnightLabeler = ref(false)
let midnightShownThisSession = false
let trailCleanup: (() => void) | null = null
let themeObserver: MutationObserver | null = null
function syncCursorTrail() {
const isHacker = document.documentElement.dataset.theme === 'hacker'
if (isHacker && !trailCleanup) {
trailCleanup = useCursorTrail()
} else if (!isHacker && trailCleanup) {
trailCleanup()
trailCleanup = null
}
}
async function fetchBatch() {
loading.value = true
apiError.value = false
const { data, error } = await useApiFetch<{ items: any[]; total: number }>('/api/queue?limit=10')
loading.value = false
if (error || !data) { apiError.value = true; return }
store.queue = data.items
store.totalRemaining = data.total
// Clean sweep queue exhausted in this batch
if (data.total === 0 && data.items.length === 0 && store.sessionLabeled > 0) {
cleanSweep.value = true
setTimeout(() => { cleanSweep.value = false }, 4000)
}
}
function checkSpeedRound(): boolean {
const now = Date.now()
recentLabels.value = recentLabels.value.filter(t => now - t < 20000)
recentLabels.value.push(now)
return recentLabels.value.length >= 5
}
async function handleLabel(name: string) {
const item = store.current
if (!item) return
// Optimistic update
store.setLastAction('label', item, name)
dismissType.value = 'label'
if (motion.rich.value) {
await new Promise(r => setTimeout(r, 350))
}
store.removeCurrentFromQueue()
store.incrementLabeled()
dismissType.value = null
consecutiveLabeled.value++
haptics.label()
// Easter eggs
if (consecutiveLabeled.value >= 10) {
onRoll.value = true
setTimeout(() => { onRoll.value = false }, 3000)
}
if (store.sessionLabeled === 50) {
fiftyDeep.value = true
setTimeout(() => { fiftyDeep.value = false }, 5000)
}
if (checkSpeedRound()) {
onRoll.value = false
speedRound.value = true
setTimeout(() => { speedRound.value = false }, 2500)
}
// Hired confetti
if (name === 'hired') {
fireConfetti()
}
// Century mark
if (store.sessionLabeled === 100) {
centuryMark.value = true
setTimeout(() => { centuryMark.value = false }, 4000)
}
// Midnight labeler once per session
if (!midnightShownThisSession) {
const h = new Date().getHours()
if (h >= 0 && h < 3) {
midnightShownThisSession = true
midnightLabeler.value = true
setTimeout(() => { midnightLabeler.value = false }, 5000)
}
}
await useApiFetch('/api/label', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: item.id, label: name }),
})
if (store.queue.length < 3) await fetchBatch()
}
async function handleSkip() {
const item = store.current
if (!item) return
store.setLastAction('skip', item)
dismissType.value = 'skip'
if (motion.rich.value) await new Promise(r => setTimeout(r, 300))
store.removeCurrentFromQueue()
dismissType.value = null
consecutiveLabeled.value = 0
haptics.skip()
await useApiFetch('/api/skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: item.id }),
})
if (store.queue.length < 3) await fetchBatch()
}
async function handleDiscard() {
const item = store.current
if (!item) return
store.setLastAction('discard', item)
dismissType.value = 'discard'
if (motion.rich.value) await new Promise(r => setTimeout(r, 400))
store.removeCurrentFromQueue()
dismissType.value = null
consecutiveLabeled.value = 0
haptics.discard()
await useApiFetch('/api/discard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: item.id }),
})
if (store.queue.length < 3) await fetchBatch()
}
async function handleUndo() {
const { data } = await useApiFetch<{ undone: { type: string; item: any } }>('/api/label/undo', { method: 'DELETE' })
if (data?.undone?.item) {
store.restoreItem(data.undone.item)
store.clearLastAction()
haptics.undo()
if (data.undone.type === 'label') {
// decrement session counter sessionLabeled is direct state in a setup store
if (store.sessionLabeled > 0) store.sessionLabeled--
}
}
}
useLabelKeyboard({
labels: [], // will be updated after labels load keyboard not active until queue loads
onLabel: handleLabel,
onSkip: handleSkip,
onDiscard: handleDiscard,
onUndo: handleUndo,
onHelp: () => { /* TODO: help overlay */ },
})
onMounted(async () => {
const { data } = await useApiFetch<any[]>('/api/config/labels')
if (data) labels.value = data
await fetchBatch()
// Cursor trail activate immediately if already in hacker mode, then watch for changes
syncCursorTrail()
themeObserver = new MutationObserver(syncCursorTrail)
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
})
onUnmounted(() => {
themeObserver?.disconnect()
themeObserver = null
if (trailCleanup) {
trailCleanup()
trailCleanup = null
}
})
</script>
<style scoped>
.label-view {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
max-width: 640px;
margin: 0 auto;
min-height: 100dvh;
}
.lv-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.queue-count {
font-family: var(--font-mono, monospace);
font-size: 0.9rem;
color: var(--color-text-secondary, #6b7a99);
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
font-family: var(--font-body, sans-serif);
animation: badge-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes badge-pop {
from { transform: scale(0.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.badge-roll { background: #ff6b35; color: #fff; }
.badge-speed { background: #7c3aed; color: #fff; }
.badge-fifty { background: var(--app-accent, #B8622A); color: var(--app-accent-text, #1a2338); }
.badge-century { background: #ffd700; color: #1a2338; }
.badge-sweep { background: var(--app-primary, #2A6080); color: #fff; }
.badge-midnight { background: #1a1a2e; color: #7c9dcf; border: 1px solid #7c9dcf; }
.header-actions {
display: flex;
gap: 0.5rem;
}
.btn-action {
padding: 0.4rem 0.8rem;
border-radius: 0.375rem;
border: 1px solid var(--color-border, #d0d7e8);
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-action:hover:not(:disabled) {
background: var(--app-primary-light, #E4F0F7);
}
.btn-action:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-danger {
border-color: var(--color-error, #ef4444);
color: var(--color-error, #ef4444);
}
.skeleton-card {
min-height: 200px;
border-radius: var(--radius-card, 1rem);
background: linear-gradient(
90deg,
var(--color-surface-raised, #f0f4fc) 25%,
var(--color-surface, #e4ebf5) 50%,
var(--color-surface-raised, #f0f4fc) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.error-display, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 3rem 1rem;
color: var(--color-text-secondary, #6b7a99);
text-align: center;
}
.card-stack-wrapper {
flex: 1;
}
</style>