Compare commits
18 commits
82eeb4defc
...
f8e911c48f
| Author | SHA1 | Date | |
|---|---|---|---|
| f8e911c48f | |||
| 2bbd925c41 | |||
| a8b1c89c62 | |||
| f8aafb2974 | |||
| d82db402a3 | |||
| ba25ee47a5 | |||
| cf69452e42 | |||
| a9f7ba1b0c | |||
| d372155e4b | |||
| 7fa62ae073 | |||
| 7bd37ef982 | |||
| f38c73db97 | |||
| 965362f5e3 | |||
| f64be8bbe0 | |||
| c5a74d3821 | |||
| 1d1f25641b | |||
| 8ec2dfddee | |||
| 92da5902ba |
24 changed files with 2810 additions and 112 deletions
123
app/api.py
123
app/api.py
|
|
@ -7,6 +7,7 @@ from __future__ import annotations
|
|||
|
||||
import hashlib
|
||||
import json
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -16,6 +17,7 @@ from pydantic import BaseModel
|
|||
|
||||
_ROOT = Path(__file__).parent.parent
|
||||
_DATA_DIR: Path = _ROOT / "data" # overridable in tests via set_data_dir()
|
||||
_CONFIG_DIR: Path | None = None # None = use real path
|
||||
|
||||
|
||||
def set_data_dir(path: Path) -> None:
|
||||
|
|
@ -24,6 +26,18 @@ def set_data_dir(path: Path) -> None:
|
|||
_DATA_DIR = path
|
||||
|
||||
|
||||
def set_config_dir(path: Path | None) -> None:
|
||||
"""Override config directory — used by tests."""
|
||||
global _CONFIG_DIR
|
||||
_CONFIG_DIR = path
|
||||
|
||||
|
||||
def _config_file() -> Path:
|
||||
if _CONFIG_DIR is not None:
|
||||
return _CONFIG_DIR / "label_tool.yaml"
|
||||
return _ROOT / "config" / "label_tool.yaml"
|
||||
|
||||
|
||||
def reset_last_action() -> None:
|
||||
"""Reset undo state — used by tests."""
|
||||
global _last_action
|
||||
|
|
@ -206,6 +220,115 @@ def get_labels():
|
|||
return _LABEL_META
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
def get_config():
|
||||
f = _config_file()
|
||||
if not f.exists():
|
||||
return {"accounts": [], "max_per_account": 500}
|
||||
raw = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
||||
return {"accounts": raw.get("accounts", []), "max_per_account": raw.get("max_per_account", 500)}
|
||||
|
||||
|
||||
class ConfigPayload(BaseModel):
|
||||
accounts: list[dict]
|
||||
max_per_account: int = 500
|
||||
|
||||
|
||||
@app.post("/api/config")
|
||||
def post_config(payload: ConfigPayload):
|
||||
f = _config_file()
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = f.with_suffix(".tmp")
|
||||
tmp.write_text(yaml.dump(payload.model_dump(), allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8")
|
||||
tmp.rename(f)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
def get_stats():
|
||||
records = _read_jsonl(_score_file())
|
||||
counts: dict[str, int] = {}
|
||||
for r in records:
|
||||
lbl = r.get("label", "")
|
||||
if lbl:
|
||||
counts[lbl] = counts.get(lbl, 0) + 1
|
||||
return {
|
||||
"total": len(records),
|
||||
"counts": counts,
|
||||
"score_file_bytes": _score_file().stat().st_size if _score_file().exists() else 0,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/stats/download")
|
||||
def download_stats():
|
||||
from fastapi.responses import FileResponse
|
||||
if not _score_file().exists():
|
||||
raise HTTPException(404, "No score file")
|
||||
return FileResponse(
|
||||
str(_score_file()),
|
||||
filename="email_score.jsonl",
|
||||
media_type="application/jsonlines",
|
||||
headers={"Content-Disposition": 'attachment; filename="email_score.jsonl"'},
|
||||
)
|
||||
|
||||
|
||||
class AccountTestRequest(BaseModel):
|
||||
account: dict
|
||||
|
||||
|
||||
@app.post("/api/accounts/test")
|
||||
def test_account(req: AccountTestRequest):
|
||||
from app.imap_fetch import test_connection
|
||||
ok, message, count = test_connection(req.account)
|
||||
return {"ok": ok, "message": message, "count": count}
|
||||
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
|
||||
@app.get("/api/fetch/stream")
|
||||
def fetch_stream(
|
||||
accounts: str = Query(default=""),
|
||||
days_back: int = Query(default=90, ge=1, le=365),
|
||||
limit: int = Query(default=150, ge=1, le=1000),
|
||||
mode: str = Query(default="wide"),
|
||||
):
|
||||
from app.imap_fetch import fetch_account_stream
|
||||
|
||||
selected_names = {n.strip() for n in accounts.split(",") if n.strip()}
|
||||
config = get_config() # reuse existing endpoint logic
|
||||
selected = [a for a in config["accounts"] if a.get("name") in selected_names]
|
||||
|
||||
def generate():
|
||||
known_keys = {_item_id(x) for x in _read_jsonl(_queue_file())}
|
||||
total_added = 0
|
||||
|
||||
for acc in selected:
|
||||
try:
|
||||
batch_emails: list[dict] = []
|
||||
for event in fetch_account_stream(acc, days_back, limit, known_keys):
|
||||
if event["type"] == "done":
|
||||
batch_emails = event.pop("emails", [])
|
||||
total_added += event["added"]
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
# Write new emails to queue after each account
|
||||
if batch_emails:
|
||||
existing = _read_jsonl(_queue_file())
|
||||
_write_jsonl(_queue_file(), existing + batch_emails)
|
||||
except Exception as exc:
|
||||
error_event = {"type": "error", "account": acc.get("name", "?"),
|
||||
"message": str(exc)}
|
||||
yield f"data: {json.dumps(error_event)}\n\n"
|
||||
|
||||
queue_size = len(_read_jsonl(_queue_file()))
|
||||
complete = {"type": "complete", "total_added": total_added, "queue_size": queue_size}
|
||||
yield f"data: {json.dumps(complete)}\n\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||
|
||||
|
||||
# Static SPA — MUST be last (catches all unmatched paths)
|
||||
_DIST = _ROOT / "web" / "dist"
|
||||
if _DIST.exists():
|
||||
|
|
|
|||
214
app/imap_fetch.py
Normal file
214
app/imap_fetch.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""Avocet — IMAP fetch utilities.
|
||||
|
||||
Shared between app/api.py (FastAPI SSE endpoint) and app/label_tool.py (Streamlit).
|
||||
No Streamlit imports here — stdlib + imaplib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import email as _email_lib
|
||||
import hashlib
|
||||
import imaplib
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from email.header import decode_header as _raw_decode
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any, Iterator
|
||||
|
||||
|
||||
# ── HTML → plain text ────────────────────────────────────────────────────────
|
||||
|
||||
class _TextExtractor(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._parts: list[str] = []
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
stripped = data.strip()
|
||||
if stripped:
|
||||
self._parts.append(stripped)
|
||||
|
||||
def get_text(self) -> str:
|
||||
return " ".join(self._parts)
|
||||
|
||||
|
||||
def strip_html(html_str: str) -> str:
|
||||
try:
|
||||
ex = _TextExtractor()
|
||||
ex.feed(html_str)
|
||||
return ex.get_text()
|
||||
except Exception:
|
||||
return re.sub(r"<[^>]+>", " ", html_str).strip()
|
||||
|
||||
|
||||
# ── IMAP decode helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _decode_str(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
parts = _raw_decode(value)
|
||||
out = []
|
||||
for part, enc in parts:
|
||||
if isinstance(part, bytes):
|
||||
out.append(part.decode(enc or "utf-8", errors="replace"))
|
||||
else:
|
||||
out.append(str(part))
|
||||
return " ".join(out).strip()
|
||||
|
||||
|
||||
def _extract_body(msg: Any) -> str:
|
||||
if msg.is_multipart():
|
||||
html_fallback: str | None = None
|
||||
for part in msg.walk():
|
||||
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"
|
||||
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 ""
|
||||
|
||||
|
||||
def entry_key(e: dict) -> str:
|
||||
"""Stable MD5 content-hash for dedup — matches label_tool.py _entry_key."""
|
||||
key = (e.get("subject", "") + (e.get("body", "") or "")[:100])
|
||||
return hashlib.md5(key.encode("utf-8", errors="replace")).hexdigest()
|
||||
|
||||
|
||||
# ── Wide search terms ────────────────────────────────────────────────────────
|
||||
|
||||
_WIDE_TERMS = [
|
||||
"interview", "phone screen", "video call", "zoom link", "schedule a call",
|
||||
"offer letter", "job offer", "offer of employment", "pleased to offer",
|
||||
"unfortunately", "not moving forward", "other candidates", "regret to inform",
|
||||
"no longer", "decided not to", "decided to go with",
|
||||
"opportunity", "interested in your background", "reached out", "great fit",
|
||||
"exciting role", "love to connect",
|
||||
"assessment", "questionnaire", "culture fit", "culture-fit", "online assessment",
|
||||
"application received", "thank you for applying", "application confirmation",
|
||||
"you applied", "your application for",
|
||||
"reschedule", "rescheduled", "new time", "moved to", "postponed", "new date",
|
||||
"job digest", "jobs you may like", "recommended jobs", "jobs for you",
|
||||
"new jobs", "job alert",
|
||||
"came across your profile", "reaching out about", "great fit for a role",
|
||||
"exciting opportunity",
|
||||
"welcome to the team", "start date", "onboarding", "first day", "we're excited to have you",
|
||||
"application", "recruiter", "recruiting", "hiring", "candidate",
|
||||
]
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_connection(acc: dict) -> tuple[bool, str, int | None]:
|
||||
"""Connect, login, select folder. Returns (ok, human_message, message_count|None)."""
|
||||
host = acc.get("host", "")
|
||||
port = int(acc.get("port", 993))
|
||||
use_ssl = acc.get("use_ssl", True)
|
||||
username = acc.get("username", "")
|
||||
password = acc.get("password", "")
|
||||
folder = acc.get("folder", "INBOX")
|
||||
if not host or not username or not password:
|
||||
return False, "Host, username, and password are all required.", None
|
||||
try:
|
||||
conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port)
|
||||
conn.login(username, password)
|
||||
_, data = conn.select(folder, readonly=True)
|
||||
count_raw = data[0].decode() if data and data[0] else "0"
|
||||
count = int(count_raw) if count_raw.isdigit() else 0
|
||||
conn.logout()
|
||||
return True, f"Connected — {count:,} message(s) in {folder}.", count
|
||||
except Exception as exc:
|
||||
return False, str(exc), None
|
||||
|
||||
|
||||
def fetch_account_stream(
|
||||
acc: dict,
|
||||
days_back: int,
|
||||
limit: int,
|
||||
known_keys: set[str],
|
||||
) -> Iterator[dict]:
|
||||
"""Generator — yields progress dicts while fetching emails via IMAP.
|
||||
|
||||
Mutates `known_keys` in place for cross-account dedup within one fetch session.
|
||||
|
||||
Yields event dicts with "type" key:
|
||||
{"type": "start", "account": str, "total_uids": int}
|
||||
{"type": "progress", "account": str, "fetched": int, "total_uids": int}
|
||||
{"type": "done", "account": str, "added": int, "skipped": int, "emails": list}
|
||||
"""
|
||||
name = acc.get("name", acc.get("username", "?"))
|
||||
host = acc.get("host", "imap.gmail.com")
|
||||
port = int(acc.get("port", 993))
|
||||
use_ssl = acc.get("use_ssl", True)
|
||||
username = acc["username"]
|
||||
password = acc["password"]
|
||||
folder = acc.get("folder", "INBOX")
|
||||
since = (datetime.now() - timedelta(days=days_back)).strftime("%d-%b-%Y")
|
||||
|
||||
conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port)
|
||||
conn.login(username, password)
|
||||
conn.select(folder, readonly=True)
|
||||
|
||||
seen_uids: dict[bytes, None] = {}
|
||||
for term in _WIDE_TERMS:
|
||||
try:
|
||||
_, data = conn.search(None, f'(SUBJECT "{term}" SINCE "{since}")')
|
||||
for uid in (data[0] or b"").split():
|
||||
seen_uids[uid] = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
uids = list(seen_uids.keys())[: limit * 3]
|
||||
yield {"type": "start", "account": name, "total_uids": len(uids)}
|
||||
|
||||
emails: list[dict] = []
|
||||
skipped = 0
|
||||
for i, uid in enumerate(uids):
|
||||
if len(emails) >= limit:
|
||||
break
|
||||
if i % 5 == 0:
|
||||
yield {"type": "progress", "account": name, "fetched": len(emails), "total_uids": len(uids)}
|
||||
try:
|
||||
_, raw_data = conn.fetch(uid, "(RFC822)")
|
||||
if not raw_data or not raw_data[0]:
|
||||
continue
|
||||
msg = _email_lib.message_from_bytes(raw_data[0][1])
|
||||
subj = _decode_str(msg.get("Subject", ""))
|
||||
from_addr = _decode_str(msg.get("From", ""))
|
||||
date = _decode_str(msg.get("Date", ""))
|
||||
body = _extract_body(msg)[:800]
|
||||
entry = {"subject": subj, "body": body, "from_addr": from_addr,
|
||||
"date": date, "account": name}
|
||||
k = entry_key(entry)
|
||||
if k not in known_keys:
|
||||
known_keys.add(k)
|
||||
emails.append(entry)
|
||||
else:
|
||||
skipped += 1
|
||||
except Exception:
|
||||
skipped += 1
|
||||
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
yield {"type": "done", "account": name, "added": len(emails), "skipped": skipped,
|
||||
"emails": emails}
|
||||
|
|
@ -152,3 +152,176 @@ def test_config_labels_returns_metadata(client):
|
|||
assert "emoji" in labels[0]
|
||||
assert "color" in labels[0]
|
||||
assert "name" in labels[0]
|
||||
|
||||
|
||||
# ── /api/config ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def config_dir(tmp_path):
|
||||
"""Give the API a writable config directory."""
|
||||
from app import api as api_module
|
||||
api_module.set_config_dir(tmp_path)
|
||||
yield tmp_path
|
||||
api_module.set_config_dir(None) # reset to default
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_dir():
|
||||
"""Expose the current _DATA_DIR set by the autouse reset_globals fixture."""
|
||||
from app import api as api_module
|
||||
return api_module._DATA_DIR
|
||||
|
||||
|
||||
def test_get_config_returns_empty_when_no_file(client, config_dir):
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["accounts"] == []
|
||||
assert data["max_per_account"] == 500
|
||||
|
||||
|
||||
def test_post_config_writes_yaml(client, config_dir):
|
||||
import yaml
|
||||
payload = {
|
||||
"accounts": [{"name": "Test", "host": "imap.test.com", "port": 993,
|
||||
"use_ssl": True, "username": "u@t.com", "password": "pw",
|
||||
"folder": "INBOX", "days_back": 30}],
|
||||
"max_per_account": 200,
|
||||
}
|
||||
r = client.post("/api/config", json=payload)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
cfg_file = config_dir / "label_tool.yaml"
|
||||
assert cfg_file.exists()
|
||||
saved = yaml.safe_load(cfg_file.read_text())
|
||||
assert saved["max_per_account"] == 200
|
||||
assert saved["accounts"][0]["name"] == "Test"
|
||||
|
||||
|
||||
def test_get_config_round_trips(client, config_dir):
|
||||
payload = {"accounts": [{"name": "R", "host": "h", "port": 993, "use_ssl": True,
|
||||
"username": "u", "password": "p", "folder": "INBOX",
|
||||
"days_back": 90}], "max_per_account": 300}
|
||||
client.post("/api/config", json=payload)
|
||||
r = client.get("/api/config")
|
||||
data = r.json()
|
||||
assert data["max_per_account"] == 300
|
||||
assert data["accounts"][0]["name"] == "R"
|
||||
|
||||
|
||||
# ── /api/stats ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def score_with_labels(tmp_path, data_dir):
|
||||
"""Write a score file with 3 labels for stats tests."""
|
||||
score_path = data_dir / "email_score.jsonl"
|
||||
records = [
|
||||
{"id": "a", "label": "interview_scheduled"},
|
||||
{"id": "b", "label": "interview_scheduled"},
|
||||
{"id": "c", "label": "rejected"},
|
||||
]
|
||||
score_path.write_text("\n".join(json.dumps(r) for r in records) + "\n")
|
||||
return records
|
||||
|
||||
|
||||
def test_stats_returns_counts(client, score_with_labels):
|
||||
r = client.get("/api/stats")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["total"] == 3
|
||||
assert data["counts"]["interview_scheduled"] == 2
|
||||
assert data["counts"]["rejected"] == 1
|
||||
|
||||
|
||||
def test_stats_empty_when_no_file(client, data_dir):
|
||||
r = client.get("/api/stats")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["total"] == 0
|
||||
assert data["counts"] == {}
|
||||
assert data["score_file_bytes"] == 0
|
||||
|
||||
|
||||
def test_stats_download_returns_file(client, score_with_labels):
|
||||
r = client.get("/api/stats/download")
|
||||
assert r.status_code == 200
|
||||
assert "jsonlines" in r.headers.get("content-type", "")
|
||||
|
||||
|
||||
def test_stats_download_404_when_no_file(client, data_dir):
|
||||
r = client.get("/api/stats/download")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ── /api/accounts/test ───────────────────────────────────────────────────────
|
||||
|
||||
def test_account_test_missing_fields(client):
|
||||
r = client.post("/api/accounts/test", json={"account": {"host": "", "username": "", "password": ""}})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["ok"] is False
|
||||
assert "required" in data["message"].lower()
|
||||
|
||||
|
||||
def test_account_test_success(client):
|
||||
from unittest.mock import MagicMock, patch
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.return_value = ("OK", [b"99"])
|
||||
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
|
||||
r = client.post("/api/accounts/test", json={"account": {
|
||||
"host": "imap.example.com", "port": 993, "use_ssl": True,
|
||||
"username": "u@example.com", "password": "pw", "folder": "INBOX",
|
||||
}})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert data["count"] == 99
|
||||
|
||||
|
||||
# ── /api/fetch/stream (SSE) ──────────────────────────────────────────────────
|
||||
|
||||
def _parse_sse(content: bytes) -> list[dict]:
|
||||
"""Parse SSE response body into list of event dicts."""
|
||||
events = []
|
||||
for line in content.decode().splitlines():
|
||||
if line.startswith("data: "):
|
||||
events.append(json.loads(line[6:]))
|
||||
return events
|
||||
|
||||
|
||||
def test_fetch_stream_no_accounts_configured(client, config_dir):
|
||||
"""With no config, stream should immediately complete with 0 added."""
|
||||
r = client.get("/api/fetch/stream?accounts=NoSuchAccount&days_back=30&limit=10")
|
||||
assert r.status_code == 200
|
||||
events = _parse_sse(r.content)
|
||||
complete = next((e for e in events if e["type"] == "complete"), None)
|
||||
assert complete is not None
|
||||
assert complete["total_added"] == 0
|
||||
|
||||
|
||||
def test_fetch_stream_with_mock_imap(client, config_dir, data_dir):
|
||||
"""With one configured account, stream should yield start/done/complete events."""
|
||||
import yaml
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Write a config with one account
|
||||
cfg = {"accounts": [{"name": "Mock", "host": "h", "port": 993, "use_ssl": True,
|
||||
"username": "u", "password": "p", "folder": "INBOX",
|
||||
"days_back": 30}], "max_per_account": 50}
|
||||
(config_dir / "label_tool.yaml").write_text(yaml.dump(cfg))
|
||||
|
||||
raw_msg = (b"Subject: Interview\r\nFrom: a@b.com\r\n"
|
||||
b"Date: Mon, 1 Mar 2026 12:00:00 +0000\r\n\r\nBody")
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.search.return_value = ("OK", [b"1"])
|
||||
mock_conn.fetch.return_value = ("OK", [(b"1 (RFC822 {N})", raw_msg)])
|
||||
|
||||
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
|
||||
r = client.get("/api/fetch/stream?accounts=Mock&days_back=30&limit=50")
|
||||
|
||||
assert r.status_code == 200
|
||||
events = _parse_sse(r.content)
|
||||
types = [e["type"] for e in events]
|
||||
assert "start" in types
|
||||
assert "done" in types
|
||||
assert "complete" in types
|
||||
|
|
|
|||
86
tests/test_imap_fetch.py
Normal file
86
tests/test_imap_fetch.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Tests for imap_fetch — IMAP calls mocked."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_test_connection_missing_fields():
|
||||
from app.imap_fetch import test_connection
|
||||
ok, msg, count = test_connection({"host": "", "username": "", "password": ""})
|
||||
assert ok is False
|
||||
assert "required" in msg.lower()
|
||||
|
||||
|
||||
def test_test_connection_success():
|
||||
from app.imap_fetch import test_connection
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.return_value = ("OK", [b"42"])
|
||||
|
||||
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
|
||||
ok, msg, count = test_connection({
|
||||
"host": "imap.example.com", "port": 993, "use_ssl": True,
|
||||
"username": "u@example.com", "password": "secret", "folder": "INBOX",
|
||||
})
|
||||
assert ok is True
|
||||
assert count == 42
|
||||
assert "42" in msg
|
||||
|
||||
|
||||
def test_test_connection_auth_failure():
|
||||
from app.imap_fetch import test_connection
|
||||
import imaplib
|
||||
|
||||
with patch("app.imap_fetch.imaplib.IMAP4_SSL", side_effect=imaplib.IMAP4.error("auth failed")):
|
||||
ok, msg, count = test_connection({
|
||||
"host": "imap.example.com", "port": 993, "use_ssl": True,
|
||||
"username": "u@example.com", "password": "wrong", "folder": "INBOX",
|
||||
})
|
||||
assert ok is False
|
||||
assert count is None
|
||||
|
||||
|
||||
def test_fetch_account_stream_yields_start_done(tmp_path):
|
||||
from app.imap_fetch import fetch_account_stream
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.search.return_value = ("OK", [b"1 2"])
|
||||
raw_msg = b"Subject: Test\r\nFrom: a@b.com\r\nDate: Mon, 1 Mar 2026 12:00:00 +0000\r\n\r\nHello"
|
||||
mock_conn.fetch.return_value = ("OK", [(b"1 (RFC822 {N})", raw_msg)])
|
||||
|
||||
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
|
||||
events = list(fetch_account_stream(
|
||||
acc={"host": "h", "port": 993, "use_ssl": True,
|
||||
"username": "u", "password": "p", "folder": "INBOX", "name": "Test"},
|
||||
days_back=30, limit=10, known_keys=set(),
|
||||
))
|
||||
|
||||
types = [e["type"] for e in events]
|
||||
assert "start" in types
|
||||
assert "done" in types
|
||||
|
||||
|
||||
def test_fetch_account_stream_deduplicates(tmp_path):
|
||||
from app.imap_fetch import fetch_account_stream
|
||||
|
||||
raw_msg = b"Subject: Dupe\r\nFrom: a@b.com\r\nDate: Mon, 1 Mar 2026 12:00:00 +0000\r\n\r\nBody"
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.search.return_value = ("OK", [b"1"])
|
||||
mock_conn.fetch.return_value = ("OK", [(b"1 (RFC822 {N})", raw_msg)])
|
||||
|
||||
known = set()
|
||||
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
|
||||
events1 = list(fetch_account_stream(
|
||||
{"host": "h", "port": 993, "use_ssl": True, "username": "u",
|
||||
"password": "p", "folder": "INBOX", "name": "T"},
|
||||
30, 10, known,
|
||||
))
|
||||
done1 = next(e for e in events1 if e["type"] == "done")
|
||||
|
||||
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
|
||||
events2 = list(fetch_account_stream(
|
||||
{"host": "h", "port": 993, "use_ssl": True, "username": "u",
|
||||
"password": "p", "folder": "INBOX", "name": "T"},
|
||||
30, 10, known,
|
||||
))
|
||||
done2 = next(e for e in events2 if e["type"] == "done")
|
||||
assert done1["added"] == 1
|
||||
assert done2["added"] == 0
|
||||
304
web/package-lock.json
generated
304
web/package-lock.json
generated
|
|
@ -14,7 +14,8 @@
|
|||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/integrations": "^14.2.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.25"
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
|
|
@ -90,6 +91,22 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
|
|
@ -843,7 +860,6 @@
|
|||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
|
|
@ -854,7 +870,6 @@
|
|||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
|
|
@ -865,7 +880,6 @@
|
|||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
|
@ -881,7 +895,6 @@
|
|||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
|
|
@ -2208,6 +2221,33 @@
|
|||
"vscode-uri": "^3.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue-macros/common": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz",
|
||||
"integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-sfc": "^3.5.22",
|
||||
"ast-kit": "^2.1.2",
|
||||
"local-pkg": "^1.1.2",
|
||||
"magic-string-ast": "^1.0.2",
|
||||
"unplugin-utils": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/vue-macros"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^2.7.0 || ^3.2.25"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.29",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
|
||||
|
|
@ -2505,7 +2545,6 @@
|
|||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
|
|
@ -2567,6 +2606,38 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
|
||||
"integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-walker-scope": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz",
|
||||
"integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.4",
|
||||
"ast-kit": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
|
|
@ -2627,7 +2698,6 @@
|
|||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
|
|
@ -2680,7 +2750,6 @@
|
|||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/config-chain": {
|
||||
|
|
@ -2940,11 +3009,16 @@
|
|||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
|
|
@ -3217,6 +3291,80 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jsesc": "bin/jsesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
|
||||
"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mlly": "^1.7.4",
|
||||
"pkg-types": "^2.3.0",
|
||||
"quansync": "^0.2.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/local-pkg/node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/local-pkg/node_modules/pkg-types": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.2",
|
||||
"exsolve": "^1.0.7",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/local-pkg/node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||
|
|
@ -3262,6 +3410,21 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string-ast": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz",
|
||||
"integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"magic-string": "^0.30.19"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
|
||||
|
|
@ -3305,7 +3468,6 @@
|
|||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
|
||||
"integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
|
|
@ -3335,7 +3497,6 @@
|
|||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
|
|
@ -3538,7 +3699,6 @@
|
|||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
|
|
@ -3557,7 +3717,6 @@
|
|||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
|
@ -3591,7 +3750,6 @@
|
|||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.1.8",
|
||||
|
|
@ -3665,7 +3823,6 @@
|
|||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
|
|
@ -3759,6 +3916,12 @@
|
|||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
|
|
@ -4006,7 +4169,6 @@
|
|||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -4118,7 +4280,6 @@
|
|||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unconfig": {
|
||||
|
|
@ -4237,7 +4398,6 @@
|
|||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
|
||||
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pathe": "^2.0.3",
|
||||
|
|
@ -4438,6 +4598,98 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
|
||||
"integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.28.6",
|
||||
"@vue-macros/common": "^3.1.1",
|
||||
"@vue/devtools-api": "^8.0.6",
|
||||
"ast-walker-scope": "^0.8.3",
|
||||
"chokidar": "^5.0.0",
|
||||
"json5": "^2.2.3",
|
||||
"local-pkg": "^1.1.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.0",
|
||||
"muggle-string": "^0.4.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"scule": "^1.3.0",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"unplugin": "^3.0.0",
|
||||
"unplugin-utils": "^0.3.1",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pinia/colada": ">=0.21.2",
|
||||
"@vue/compiler-sfc": "^3.5.17",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@pinia/colada": {
|
||||
"optional": true
|
||||
},
|
||||
"@vue/compiler-sfc": {
|
||||
"optional": true
|
||||
},
|
||||
"pinia": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router/node_modules/@vue/devtools-api": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.7.tgz",
|
||||
"integrity": "sha512-tc1TXAxclsn55JblLkFVcIRG7MeSJC4fWsPjfM7qu/IcmPUYnQ5Q8vzWwBpyDY24ZjmZTUCCwjRSNbx58IhlAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-kit": "^8.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router/node_modules/@vue/devtools-kit": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.7.tgz",
|
||||
"integrity": "sha512-H6esJGHGl5q0E9iV3m2EoBQHJ+V83WMW83A0/+Fn95eZ2iIvdsq4+UCS6yT/Fdd4cGZSchx/MdWDreM3WqMsDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-shared": "^8.0.7",
|
||||
"birpc": "^2.6.1",
|
||||
"hookable": "^5.5.3",
|
||||
"perfect-debounce": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router/node_modules/@vue/devtools-shared": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.7.tgz",
|
||||
"integrity": "sha512-CgAb9oJH5NUmbQRdYDj/1zMiaICYSLtm+B1kxcP72LBrifGAjUmt8bx52dDH1gWRPlQgxGPqpAMKavzVirAEhA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-router/node_modules/perfect-debounce": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-router/node_modules/unplugin": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
|
||||
"integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"picomatch": "^4.0.3",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-tsc": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.5.tgz",
|
||||
|
|
@ -4482,7 +4734,6 @@
|
|||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
|
|
@ -4657,6 +4908,21 @@
|
|||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/integrations": "^14.2.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.25"
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
<template>
|
||||
<div id="app" :class="{ 'rich-motion': motion.rich.value }">
|
||||
<LabelView />
|
||||
<AppSidebar />
|
||||
<main class="app-main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useMotion } from './composables/useMotion'
|
||||
import { useHackerMode } from './composables/useEasterEgg'
|
||||
import LabelView from './views/LabelView.vue'
|
||||
import AppSidebar from './components/AppSidebar.vue'
|
||||
|
||||
const motion = useMotion()
|
||||
const { restore } = useHackerMode()
|
||||
|
|
@ -34,6 +38,15 @@ body {
|
|||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
min-height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
min-width: 0; /* prevent flex blowout */
|
||||
margin-left: var(--sidebar-width, 200px);
|
||||
transition: margin-left 250ms ease;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
274
web/src/components/AppSidebar.vue
Normal file
274
web/src/components/AppSidebar.vue
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
<template>
|
||||
<!-- Mobile backdrop scrim -->
|
||||
<div
|
||||
v-if="isMobile && !stowed"
|
||||
class="sidebar-scrim"
|
||||
aria-hidden="true"
|
||||
@click="stow()"
|
||||
/>
|
||||
|
||||
<nav
|
||||
class="sidebar"
|
||||
:class="{ stowed, mobile: isMobile }"
|
||||
:style="{ '--sidebar-w': stowed ? '56px' : '200px' }"
|
||||
aria-label="App navigation"
|
||||
>
|
||||
<!-- Logo + stow toggle -->
|
||||
<div class="sidebar-header">
|
||||
<span v-if="!stowed" class="sidebar-logo">
|
||||
<span class="logo-icon">🐦</span>
|
||||
<span class="logo-name">Avocet</span>
|
||||
</span>
|
||||
<button
|
||||
class="stow-btn"
|
||||
:aria-label="stowed ? 'Expand navigation' : 'Collapse navigation'"
|
||||
@click="toggle()"
|
||||
>
|
||||
{{ stowed ? '›' : '‹' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Nav items -->
|
||||
<ul class="nav-list" role="list">
|
||||
<li v-for="item in navItems" :key="item.path">
|
||||
<RouterLink
|
||||
:to="item.path"
|
||||
class="nav-item"
|
||||
:title="stowed ? item.label : ''"
|
||||
@click="isMobile && stow()"
|
||||
>
|
||||
<span class="nav-icon" aria-hidden="true">{{ item.icon }}</span>
|
||||
<span v-if="!stowed" class="nav-label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile hamburger button rendered outside the sidebar so it's visible when stowed -->
|
||||
<button
|
||||
v-if="isMobile && stowed"
|
||||
class="mobile-hamburger"
|
||||
aria-label="Open navigation"
|
||||
@click="toggle()"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const LS_KEY = 'cf-avocet-nav-stowed'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', icon: '🃏', label: 'Label' },
|
||||
{ path: '/fetch', icon: '📥', label: 'Fetch' },
|
||||
{ path: '/stats', icon: '📊', label: 'Stats' },
|
||||
{ path: '/settings', icon: '⚙️', label: 'Settings' },
|
||||
]
|
||||
|
||||
const stowed = ref(localStorage.getItem(LS_KEY) === 'true')
|
||||
const winWidth = ref(window.innerWidth)
|
||||
const isMobile = computed(() => winWidth.value < 640)
|
||||
|
||||
function toggle() {
|
||||
stowed.value = !stowed.value
|
||||
localStorage.setItem(LS_KEY, String(stowed.value))
|
||||
// Update CSS variable on :root so .app-main margin-left syncs
|
||||
document.documentElement.style.setProperty('--sidebar-width', stowed.value ? '56px' : '200px')
|
||||
}
|
||||
|
||||
function stow() {
|
||||
stowed.value = true
|
||||
localStorage.setItem(LS_KEY, 'true')
|
||||
document.documentElement.style.setProperty('--sidebar-width', '56px')
|
||||
}
|
||||
|
||||
function onResize() { winWidth.value = window.innerWidth }
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize)
|
||||
// Apply persisted sidebar width to :root on mount
|
||||
document.documentElement.style.setProperty('--sidebar-width', stowed.value ? '56px' : '200px')
|
||||
// On mobile, default to stowed
|
||||
if (isMobile.value && !localStorage.getItem(LS_KEY)) {
|
||||
stowed.value = true
|
||||
document.documentElement.style.setProperty('--sidebar-width', '56px')
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--sidebar-w, 200px);
|
||||
background: var(--color-surface-raised, #e4ebf5);
|
||||
border-right: 1px solid var(--color-border, #d0d7e8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 200;
|
||||
transition: width 250ms ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar.stowed {
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
/* Mobile: slide in/out from left */
|
||||
.sidebar.mobile {
|
||||
box-shadow: 2px 0 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.sidebar.mobile.stowed {
|
||||
transform: translateX(-100%);
|
||||
width: 200px; /* keep width so slide-in looks right */
|
||||
transition: transform 250ms ease, width 250ms ease;
|
||||
}
|
||||
|
||||
.sidebar.mobile:not(.stowed) {
|
||||
transform: translateX(0);
|
||||
transition: transform 250ms ease;
|
||||
}
|
||||
|
||||
.sidebar-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 199;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0.5rem 0.75rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border, #d0d7e8);
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-name {
|
||||
font-family: var(--font-display, var(--font-body, sans-serif));
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--app-primary, #2A6080);
|
||||
}
|
||||
|
||||
.stow-btn {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary, #6b7a99);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.stow-btn:hover {
|
||||
background: var(--color-border, #d0d7e8);
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
list-style: none;
|
||||
padding: 0.5rem 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--color-text, #1a2338);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: color-mix(in srgb, var(--app-primary, #2A6080) 10%, transparent);
|
||||
}
|
||||
|
||||
.nav-item.router-link-active {
|
||||
background: color-mix(in srgb, var(--app-primary, #2A6080) 15%, transparent);
|
||||
color: var(--app-primary, #2A6080);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item.router-link-active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--app-primary, #2A6080);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Mobile hamburger — visible when sidebar is stowed on mobile */
|
||||
.mobile-hamburger {
|
||||
position: fixed;
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
z-index: 201;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
background: var(--color-surface-raised, #e4ebf5);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar,
|
||||
.sidebar.mobile,
|
||||
.sidebar.mobile.stowed {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { mount } from '@vue/test-utils'
|
||||
import EmailCardStack from './EmailCardStack.vue'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
const item = {
|
||||
id: 'abc',
|
||||
|
|
@ -45,15 +45,70 @@ describe('EmailCardStack', () => {
|
|||
expect(wrapperClasses).not.toContain('dismiss-skip')
|
||||
})
|
||||
|
||||
it('emits drag-start on dragstart event', async () => {
|
||||
// JSDOM doesn't implement setPointerCapture — mock it on the element.
|
||||
// Also use dispatchEvent(new PointerEvent) directly because @vue/test-utils
|
||||
// .trigger() tries to assign clientX on a MouseEvent (read-only in JSDOM).
|
||||
function mockPointerCapture(element: Element) {
|
||||
;(element as any).setPointerCapture = vi.fn()
|
||||
;(element as any).releasePointerCapture = vi.fn()
|
||||
}
|
||||
|
||||
function fire(element: Element, type: string, init: PointerEventInit) {
|
||||
element.dispatchEvent(new PointerEvent(type, { bubbles: true, ...init }))
|
||||
}
|
||||
|
||||
it('emits drag-start on pointerdown', async () => {
|
||||
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
|
||||
await w.find('.card-stack').trigger('dragstart')
|
||||
const el = w.find('.card-wrapper').element
|
||||
mockPointerCapture(el)
|
||||
fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 })
|
||||
await w.vm.$nextTick()
|
||||
expect(w.emitted('drag-start')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits drag-end on dragend event', async () => {
|
||||
it('emits drag-end on pointerup', async () => {
|
||||
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
|
||||
await w.find('.card-stack').trigger('dragend')
|
||||
const el = w.find('.card-wrapper').element
|
||||
mockPointerCapture(el)
|
||||
fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 })
|
||||
fire(el, 'pointerup', { pointerId: 1, clientX: 200, clientY: 300 })
|
||||
await w.vm.$nextTick()
|
||||
expect(w.emitted('drag-end')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits discard when released in left zone (x < 7% viewport)', async () => {
|
||||
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
|
||||
const el = w.find('.card-wrapper').element
|
||||
mockPointerCapture(el)
|
||||
// JSDOM window.innerWidth defaults to 1024; 7% = 71.7px
|
||||
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
|
||||
fire(el, 'pointermove', { pointerId: 1, clientX: 30, clientY: 300 })
|
||||
fire(el, 'pointerup', { pointerId: 1, clientX: 30, clientY: 300 })
|
||||
await w.vm.$nextTick()
|
||||
expect(w.emitted('discard')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits skip when released in right zone (x > 93% viewport)', async () => {
|
||||
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
|
||||
const el = w.find('.card-wrapper').element
|
||||
mockPointerCapture(el)
|
||||
// JSDOM window.innerWidth defaults to 1024; 93% = 952px
|
||||
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
|
||||
fire(el, 'pointermove', { pointerId: 1, clientX: 1000, clientY: 300 })
|
||||
fire(el, 'pointerup', { pointerId: 1, clientX: 1000, clientY: 300 })
|
||||
await w.vm.$nextTick()
|
||||
expect(w.emitted('skip')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not emit action on pointerup without movement past zone', async () => {
|
||||
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
|
||||
const el = w.find('.card-wrapper').element
|
||||
mockPointerCapture(el)
|
||||
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
|
||||
fire(el, 'pointerup', { pointerId: 1, clientX: 512, clientY: 300 })
|
||||
await w.vm.$nextTick()
|
||||
expect(w.emitted('discard')).toBeFalsy()
|
||||
expect(w.emitted('skip')).toBeFalsy()
|
||||
expect(w.emitted('label')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
<template>
|
||||
<div
|
||||
class="card-stack"
|
||||
ref="stackEl"
|
||||
:draggable="motion.rich.value"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
:class="{ 'bucket-mode': isBucketMode && motion.rich.value }"
|
||||
>
|
||||
<!-- Depth shadow cards (visual stack effect) -->
|
||||
<div class="card-shadow card-shadow-2" aria-hidden="true" />
|
||||
|
|
@ -14,8 +11,12 @@
|
|||
<div
|
||||
class="card-wrapper"
|
||||
ref="cardEl"
|
||||
:class="dismissClass"
|
||||
:class="[dismissClass, { 'is-held': isHeld }]"
|
||||
:style="cardStyle"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerCancel"
|
||||
>
|
||||
<EmailCard
|
||||
:item="item"
|
||||
|
|
@ -29,7 +30,6 @@
|
|||
|
||||
<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'
|
||||
|
|
@ -41,30 +41,112 @@ const props = defineProps<{
|
|||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
label: [name: string]
|
||||
skip: []
|
||||
discard: []
|
||||
'drag-start': []
|
||||
'drag-end': []
|
||||
label: [name: string]
|
||||
skip: []
|
||||
discard: []
|
||||
'drag-start': []
|
||||
'drag-end': []
|
||||
'zone-hover': ['discard' | 'skip' | null]
|
||||
'bucket-hover': [string | null]
|
||||
}>()
|
||||
|
||||
const motion = useMotion()
|
||||
const cardEl = ref<HTMLElement | null>(null)
|
||||
const stackEl = ref<HTMLElement | null>(null)
|
||||
const motion = useMotion()
|
||||
const cardEl = 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
|
||||
},
|
||||
})
|
||||
// Toss gesture state
|
||||
const isHeld = ref(false)
|
||||
const pickupX = ref(0)
|
||||
const pickupY = ref(0)
|
||||
const deltaX = ref(0)
|
||||
const deltaY = ref(0)
|
||||
const hoveredZone = ref<'discard' | 'skip' | null>(null)
|
||||
const hoveredBucketName = ref<string | null>(null)
|
||||
|
||||
// Zone threshold: 7% of viewport width on each side
|
||||
const ZONE_PCT = 0.07
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!motion.rich.value) return
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
pickupX.value = e.clientX
|
||||
pickupY.value = e.clientY
|
||||
deltaX.value = 0
|
||||
deltaY.value = 0
|
||||
isHeld.value = true
|
||||
hoveredZone.value = null
|
||||
hoveredBucketName.value = null
|
||||
emit('drag-start')
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isHeld.value) return
|
||||
deltaX.value = e.clientX - pickupX.value
|
||||
deltaY.value = e.clientY - pickupY.value
|
||||
|
||||
const vw = window.innerWidth
|
||||
const zone: 'discard' | 'skip' | null =
|
||||
e.clientX < vw * ZONE_PCT ? 'discard' :
|
||||
e.clientX > vw * (1 - ZONE_PCT) ? 'skip' :
|
||||
null
|
||||
if (zone !== hoveredZone.value) {
|
||||
hoveredZone.value = zone
|
||||
emit('zone-hover', zone)
|
||||
}
|
||||
|
||||
// Bucket detection via hit-test (works through overlapping elements).
|
||||
// Optional chain guards against JSDOM in tests (doesn't implement elementsFromPoint).
|
||||
const els = document.elementsFromPoint?.(e.clientX, e.clientY) ?? []
|
||||
const bucket = els.find(el => el.hasAttribute('data-label-key'))
|
||||
const bucketName = bucket?.getAttribute('data-label-key') ?? null
|
||||
if (bucketName !== hoveredBucketName.value) {
|
||||
hoveredBucketName.value = bucketName
|
||||
emit('bucket-hover', bucketName)
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (!isHeld.value) return
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
isHeld.value = false
|
||||
emit('drag-end')
|
||||
emit('zone-hover', null)
|
||||
emit('bucket-hover', null)
|
||||
|
||||
if (hoveredZone.value === 'discard') {
|
||||
hoveredZone.value = null
|
||||
hoveredBucketName.value = null
|
||||
emit('discard')
|
||||
} else if (hoveredZone.value === 'skip') {
|
||||
hoveredZone.value = null
|
||||
hoveredBucketName.value = null
|
||||
emit('skip')
|
||||
} else if (hoveredBucketName.value) {
|
||||
const name = hoveredBucketName.value
|
||||
hoveredZone.value = null
|
||||
hoveredBucketName.value = null
|
||||
emit('label', name)
|
||||
} else {
|
||||
// Snap back — reset deltas
|
||||
deltaX.value = 0
|
||||
deltaY.value = 0
|
||||
hoveredZone.value = null
|
||||
hoveredBucketName.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerCancel(e: PointerEvent) {
|
||||
if (!isHeld.value) return
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
isHeld.value = false
|
||||
deltaX.value = 0
|
||||
deltaY.value = 0
|
||||
hoveredZone.value = null
|
||||
hoveredBucketName.value = null
|
||||
emit('drag-end')
|
||||
emit('zone-hover', null)
|
||||
emit('bucket-hover', null)
|
||||
}
|
||||
|
||||
const dismissClass = computed(() => {
|
||||
if (!props.dismissType) return null
|
||||
|
|
@ -72,28 +154,56 @@ const dismissClass = computed(() => {
|
|||
})
|
||||
|
||||
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'
|
||||
if (!motion.rich.value || !isHeld.value) return {}
|
||||
|
||||
// Aura color: zone > bucket > neutral
|
||||
const aura =
|
||||
hoveredZone.value === 'discard' ? 'rgba(244,67,54,0.25)' :
|
||||
hoveredZone.value === 'skip' ? 'rgba(255,152,0,0.25)' :
|
||||
hoveredBucketName.value ? 'rgba(42,96,128,0.20)' :
|
||||
'transparent'
|
||||
|
||||
return {
|
||||
transform: `translateX(${dragX.value}px) rotate(${tilt}deg)`,
|
||||
opacity,
|
||||
background: color,
|
||||
transition: isSwiping.value ? 'none' : 'all 0.3s ease',
|
||||
transform: `translate(${deltaX.value}px, ${deltaY.value}px) scale(0.35)`,
|
||||
borderRadius: '50%',
|
||||
background: aura,
|
||||
transition: 'border-radius 150ms ease, background 150ms ease',
|
||||
cursor: 'grabbing',
|
||||
zIndex: 100,
|
||||
userSelect: 'none',
|
||||
}
|
||||
})
|
||||
|
||||
function onDragStart() { emit('drag-start') }
|
||||
function onDragEnd() { emit('drag-end') }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-stack {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
max-height: 2000px; /* effectively unlimited — needed for max-height transition */
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 280ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
min-height 280ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* Bucket mode: collapse card stack to a small pill so buckets get more room */
|
||||
.card-stack.bucket-mode {
|
||||
min-height: 0;
|
||||
max-height: 90px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-stack.bucket-mode .card-shadow {
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
.card-stack.bucket-mode .card-wrapper {
|
||||
clip-path: inset(35% 15% round 0.75rem);
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
|
|
@ -102,6 +212,7 @@ function onDragEnd() { emit('drag-end') }
|
|||
border-radius: var(--radius-card, 1rem);
|
||||
background: var(--color-surface-raised, #fff);
|
||||
border: 1px solid var(--color-border, #e0e4ed);
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
.card-shadow-1 { transform: translateY(8px) scale(0.97); opacity: 0.6; }
|
||||
.card-shadow-2 { transform: translateY(16px) scale(0.94); opacity: 0.35; }
|
||||
|
|
@ -111,17 +222,28 @@ function onDragEnd() { emit('drag-end') }
|
|||
z-index: 1;
|
||||
border-radius: var(--radius-card, 1rem);
|
||||
background: var(--color-surface-raised, #fff);
|
||||
will-change: transform, opacity;
|
||||
will-change: clip-path, opacity;
|
||||
clip-path: inset(0% 0% round 1rem);
|
||||
touch-action: none; /* prevent scroll from stealing the gesture */
|
||||
cursor: grab;
|
||||
transition:
|
||||
clip-path 260ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 220ms ease;
|
||||
}
|
||||
.card-wrapper.is-held {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Dismissal animations — only active under .rich-motion on root */
|
||||
:global(.rich-motion) .card-wrapper.dismiss-label {
|
||||
/* Dismissal animations — dismiss class is only applied during the motion.rich await window,
|
||||
so no ancestor guard needed; :global(.rich-motion) was being miscompiled by Vue's scoped
|
||||
CSS transformer (dropping the descendant selector entirely). */
|
||||
.card-wrapper.dismiss-label {
|
||||
animation: fileAway var(--card-dismiss, 350ms ease-in) forwards;
|
||||
}
|
||||
:global(.rich-motion) .card-wrapper.dismiss-discard {
|
||||
.card-wrapper.dismiss-discard {
|
||||
animation: crumple var(--card-dismiss, 350ms ease-in) forwards;
|
||||
}
|
||||
:global(.rich-motion) .card-wrapper.dismiss-skip {
|
||||
.card-wrapper.dismiss-skip {
|
||||
animation: slideUnder var(--card-skip, 300ms ease-out) forwards;
|
||||
}
|
||||
|
||||
|
|
@ -135,4 +257,11 @@ function onDragEnd() { emit('drag-end') }
|
|||
@keyframes slideUnder {
|
||||
to { transform: translateX(110%) rotate(5deg); opacity: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card-stack,
|
||||
.card-wrapper {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -31,4 +31,22 @@ describe('LabelBucketGrid', () => {
|
|||
expect(btn.text()).toContain('1')
|
||||
expect(btn.text()).toContain('🗓️')
|
||||
})
|
||||
|
||||
it('marks button as drop-target when hoveredBucket matches label name', () => {
|
||||
const w = mount(LabelBucketGrid, {
|
||||
props: { labels, isBucketMode: true, hoveredBucket: 'interview_scheduled' },
|
||||
})
|
||||
const btns = w.findAll('[data-testid="label-btn"]')
|
||||
expect(btns[0].classes()).toContain('is-drop-target')
|
||||
expect(btns[1].classes()).not.toContain('is-drop-target')
|
||||
})
|
||||
|
||||
it('no button marked as drop-target when hoveredBucket is null', () => {
|
||||
const w = mount(LabelBucketGrid, {
|
||||
props: { labels, isBucketMode: false, hoveredBucket: null },
|
||||
})
|
||||
w.findAll('[data-testid="label-btn"]').forEach(btn => {
|
||||
expect(btn.classes()).not.toContain('is-drop-target')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,14 +4,12 @@
|
|||
v-for="label in labels"
|
||||
:key="label.key"
|
||||
data-testid="label-btn"
|
||||
:data-label-key="label.name"
|
||||
class="label-btn"
|
||||
:class="{ 'is-drop-target': dragOverLabel === label.name }"
|
||||
:class="{ 'is-drop-target': props.hoveredBucket === label.name }"
|
||||
:style="{ '--label-color': label.color }"
|
||||
:aria-label="`Label as ${label.name.replace(/_/g, ' ')} (key: ${label.key})`"
|
||||
@click="$emit('label', label.name)"
|
||||
@dragover.prevent="dragOverLabel = label.name"
|
||||
@dragleave="dragOverLabel = null"
|
||||
@drop.prevent="onDrop(label.name)"
|
||||
>
|
||||
<span class="key-hint" aria-hidden="true">{{ label.key }}</span>
|
||||
<span class="emoji" aria-hidden="true">{{ label.emoji }}</span>
|
||||
|
|
@ -21,34 +19,30 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Label { name: string; emoji: string; color: string; key: string }
|
||||
|
||||
const props = defineProps<{ labels: Label[]; isBucketMode: boolean }>()
|
||||
const emit = defineEmits<{ label: [name: string] }>()
|
||||
|
||||
const dragOverLabel = ref<string | null>(null)
|
||||
|
||||
function onDrop(name: string) {
|
||||
dragOverLabel.value = null
|
||||
emit('label', name)
|
||||
}
|
||||
const props = defineProps<{
|
||||
labels: Label[]
|
||||
isBucketMode: boolean
|
||||
hoveredBucket?: string | null
|
||||
}>()
|
||||
const emit = defineEmits<{ label: [name: string] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
transition: all var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1));
|
||||
transition: gap var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
|
||||
padding var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1));
|
||||
}
|
||||
|
||||
/* Mobile: 3-column numpad layout */
|
||||
@media (max-width: 480px) {
|
||||
.label-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
/* 10th button (hired / key h) — centered below the 3×3 like a numpad 0 */
|
||||
.label-btn:last-child {
|
||||
grid-column: 1 / -1;
|
||||
max-width: calc(33.333% - 0.34rem);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.label-grid.bucket-mode {
|
||||
|
|
@ -69,7 +63,14 @@ function onDrop(name: string) {
|
|||
background: transparent;
|
||||
color: var(--color-text, #1a2338);
|
||||
cursor: pointer;
|
||||
transition: all var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1));
|
||||
transition: min-height var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
|
||||
padding var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
|
||||
border-width var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
|
||||
font-size var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
|
||||
background var(--transition, 200ms ease),
|
||||
transform var(--transition, 200ms ease),
|
||||
box-shadow var(--transition, 200ms ease),
|
||||
opacity var(--transition, 200ms ease);
|
||||
font-family: var(--font-body, sans-serif);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,4 +71,19 @@ describe('UndoToast', () => {
|
|||
const w = mount(UndoToast, { props: { action: labelAction } })
|
||||
expect(w.find('[role="status"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits expire when tick fires with timestamp beyond DURATION', async () => {
|
||||
let capturedTick: FrameRequestCallback | null = null
|
||||
vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
|
||||
capturedTick = fn
|
||||
return 1
|
||||
})
|
||||
vi.spyOn(performance, 'now').mockReturnValue(0)
|
||||
const w = mount(UndoToast, { props: { action: labelAction } })
|
||||
await import('vue').then(v => v.nextTick())
|
||||
// Simulate a tick timestamp 6 seconds in — beyond the 5-second DURATION
|
||||
if (capturedTick) capturedTick(6000)
|
||||
await import('vue').then(v => v.nextTick())
|
||||
expect(w.emitted('expire')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { ref, onMounted, onUnmounted, computed } from 'vue'
|
|||
import type { LastAction } from '../stores/label'
|
||||
|
||||
const props = defineProps<{ action: LastAction }>()
|
||||
defineEmits<{ undo: [] }>()
|
||||
const emit = defineEmits<{ undo: []; expire: [] }>()
|
||||
|
||||
const DURATION = 5000
|
||||
const elapsed = ref(0)
|
||||
|
|
@ -30,14 +30,15 @@ const label = computed(() => {
|
|||
})
|
||||
|
||||
function tick(ts: number) {
|
||||
if (!start) start = ts
|
||||
elapsed.value = ts - start
|
||||
if (elapsed.value < DURATION) {
|
||||
raf = requestAnimationFrame(tick)
|
||||
} else {
|
||||
emit('expire')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { raf = requestAnimationFrame(tick) })
|
||||
onMounted(() => { start = performance.now(); raf = requestAnimationFrame(tick) })
|
||||
onUnmounted(() => cancelAnimationFrame(raf))
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,3 +18,33 @@ export async function useApiFetch<T>(
|
|||
return { data: null, error: { kind: 'network', message: String(e) } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an SSE connection. Returns a cleanup function.
|
||||
* onEvent receives each parsed JSON payload.
|
||||
* onComplete is called when the server sends a {"type":"complete"} event.
|
||||
* onError is called on connection error.
|
||||
*/
|
||||
export function useApiSSE(
|
||||
url: string,
|
||||
onEvent: (data: Record<string, unknown>) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (e: Event) => void,
|
||||
): () => void {
|
||||
const es = new EventSource(url)
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data) as Record<string, unknown>
|
||||
onEvent(data)
|
||||
if (data.type === 'complete') {
|
||||
es.close()
|
||||
onComplete?.()
|
||||
}
|
||||
} catch { /* ignore malformed events */ }
|
||||
}
|
||||
es.onerror = (e) => {
|
||||
onError?.(e)
|
||||
es.close()
|
||||
}
|
||||
return () => es.close()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,4 +89,18 @@ describe('useLabelKeyboard', () => {
|
|||
window.dispatchEvent(new KeyboardEvent('keydown', { key: '1' }))
|
||||
expect(onLabel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('evaluates labels getter on each keypress', () => {
|
||||
const labelList: { name: string; key: string; emoji: string; color: string }[] = []
|
||||
const onLabel = vi.fn()
|
||||
const { cleanup } = useLabelKeyboard({ labels: () => labelList, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
|
||||
cleanups.push(cleanup)
|
||||
// Before labels loaded — pressing '1' does nothing
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }))
|
||||
expect(onLabel).not.toHaveBeenCalled()
|
||||
// Add a label (simulating async load)
|
||||
labelList.push({ name: 'interview_scheduled', key: '1', emoji: '🗓️', color: '#4CAF50' })
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }))
|
||||
expect(onLabel).toHaveBeenCalledWith('interview_scheduled')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { onUnmounted, getCurrentInstance } from 'vue'
|
|||
interface Label { name: string; key: string; emoji: string; color: string }
|
||||
|
||||
interface Options {
|
||||
labels: Label[]
|
||||
labels: Label[] | (() => Label[])
|
||||
onLabel: (name: string) => void
|
||||
onSkip: () => void
|
||||
onDiscard: () => void
|
||||
|
|
@ -12,12 +12,13 @@ interface Options {
|
|||
}
|
||||
|
||||
export function useLabelKeyboard(opts: Options) {
|
||||
const keyMap = new Map(opts.labels.map(l => [l.key.toLowerCase(), l.name]))
|
||||
|
||||
function handler(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement) return
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const k = e.key.toLowerCase()
|
||||
// Evaluate labels lazily so reactive updates work
|
||||
const labelList = typeof opts.labels === 'function' ? opts.labels() : opts.labels
|
||||
const keyMap = new Map(labelList.map(l => [l.key.toLowerCase(), l.name]))
|
||||
if (keyMap.has(k)) { opts.onLabel(keyMap.get(k)!); return }
|
||||
if (k === 'h') { opts.onLabel('hired'); return }
|
||||
if (k === 's') { opts.onSkip(); return }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { router } from './router'
|
||||
// Self-hosted fonts — no Google Fonts CDN (privacy requirement)
|
||||
import '@fontsource/fraunces/400.css'
|
||||
import '@fontsource/fraunces/700.css'
|
||||
|
|
@ -11,6 +12,9 @@ import './assets/theme.css'
|
|||
import './assets/avocet.css'
|
||||
import App from './App.vue'
|
||||
|
||||
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
|
|
|||
17
web/src/router/index.ts
Normal file
17
web/src/router/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import LabelView from '../views/LabelView.vue'
|
||||
|
||||
// Views are lazy-loaded to keep initial bundle small
|
||||
const FetchView = () => import('../views/FetchView.vue')
|
||||
const StatsView = () => import('../views/StatsView.vue')
|
||||
const SettingsView = () => import('../views/SettingsView.vue')
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: LabelView, meta: { title: 'Label' } },
|
||||
{ path: '/fetch', component: FetchView, meta: { title: 'Fetch' } },
|
||||
{ path: '/stats', component: StatsView, meta: { title: 'Stats' } },
|
||||
{ path: '/settings', component: SettingsView, meta: { title: 'Settings' } },
|
||||
],
|
||||
})
|
||||
459
web/src/views/FetchView.vue
Normal file
459
web/src/views/FetchView.vue
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
<template>
|
||||
<div class="fetch-view">
|
||||
<h1 class="page-title">📥 Fetch Emails</h1>
|
||||
|
||||
<!-- No accounts -->
|
||||
<div v-if="!loading && accounts.length === 0" class="empty-notice">
|
||||
No accounts configured.
|
||||
<RouterLink to="/settings" class="link">Go to Settings →</RouterLink>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Account selection -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Accounts</h2>
|
||||
<label
|
||||
v-for="acc in accounts"
|
||||
:key="acc.name"
|
||||
class="account-check"
|
||||
>
|
||||
<input v-model="selectedAccounts" type="checkbox" :value="acc.name" />
|
||||
{{ acc.name || acc.username }}
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Standard fetch options -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Options</h2>
|
||||
|
||||
<label class="field field-inline">
|
||||
<span class="field-label">Days back</span>
|
||||
<input v-model.number="daysBack" type="range" min="7" max="365" class="slider" />
|
||||
<span class="field-value">{{ daysBack }}</span>
|
||||
</label>
|
||||
|
||||
<label class="field field-inline">
|
||||
<span class="field-label">Max per account</span>
|
||||
<input v-model.number="limitPerAccount" type="number" min="10" max="2000" class="field-num" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Fetch button -->
|
||||
<button
|
||||
class="btn-primary btn-fetch"
|
||||
:disabled="fetching || selectedAccounts.length === 0"
|
||||
@click="startFetch"
|
||||
>
|
||||
{{ fetching ? 'Fetching…' : '📥 Fetch from IMAP' }}
|
||||
</button>
|
||||
|
||||
<!-- Progress bars -->
|
||||
<div v-if="progress.length > 0" class="progress-section">
|
||||
<div v-for="p in progress" :key="p.account" class="progress-row">
|
||||
<span class="progress-name">{{ p.account }}</span>
|
||||
<div class="progress-track">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:class="{ done: p.done, error: p.error }"
|
||||
:style="{ width: `${p.pct}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="progress-label">{{ p.label }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="completeMsg" class="complete-msg">{{ completeMsg }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Targeted fetch (collapsible) -->
|
||||
<details class="targeted-section">
|
||||
<summary class="targeted-summary">🎯 Targeted fetch (by date range + keyword)</summary>
|
||||
<div class="targeted-fields">
|
||||
<div class="field-row">
|
||||
<label class="field field-grow">
|
||||
<span>From</span>
|
||||
<input v-model="targetSince" type="date" />
|
||||
</label>
|
||||
<label class="field field-grow">
|
||||
<span>To</span>
|
||||
<input v-model="targetBefore" type="date" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Search term (optional)</span>
|
||||
<input v-model="targetTerm" type="text" placeholder="e.g. interview" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Match field</span>
|
||||
<select v-model="targetField" class="field-select">
|
||||
<option value="either">Subject or From</option>
|
||||
<option value="subject">Subject only</option>
|
||||
<option value="from">From only</option>
|
||||
<option value="none">No filter (date range only)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn-secondary" :disabled="fetching" @click="startTargetedFetch">
|
||||
🎯 Targeted fetch
|
||||
</button>
|
||||
<p class="targeted-note">
|
||||
Targeted fetch uses the same SSE stream — progress appears above.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useApiFetch, useApiSSE } from '../composables/useApi'
|
||||
|
||||
interface Account { name: string; username: string; days_back: number }
|
||||
|
||||
interface ProgressRow {
|
||||
account: string
|
||||
pct: number
|
||||
label: string
|
||||
done: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
const accounts = ref<Account[]>([])
|
||||
const selectedAccounts = ref<string[]>([])
|
||||
const daysBack = ref(90)
|
||||
const limitPerAccount = ref(150)
|
||||
const loading = ref(true)
|
||||
const fetching = ref(false)
|
||||
const progress = ref<ProgressRow[]>([])
|
||||
const completeMsg = ref('')
|
||||
|
||||
// Targeted fetch
|
||||
const targetSince = ref('')
|
||||
const targetBefore = ref('')
|
||||
const targetTerm = ref('')
|
||||
const targetField = ref('either')
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true
|
||||
const { data } = await useApiFetch<{ accounts: Account[]; max_per_account: number }>('/api/config')
|
||||
loading.value = false
|
||||
if (data) {
|
||||
accounts.value = data.accounts
|
||||
selectedAccounts.value = data.accounts.map(a => a.name)
|
||||
limitPerAccount.value = data.max_per_account
|
||||
}
|
||||
}
|
||||
|
||||
function initProgress() {
|
||||
progress.value = selectedAccounts.value.map(name => ({
|
||||
account: name, pct: 0, label: 'waiting…', done: false, error: false,
|
||||
}))
|
||||
completeMsg.value = ''
|
||||
}
|
||||
|
||||
function startFetch() {
|
||||
if (fetching.value || selectedAccounts.value.length === 0) return
|
||||
fetching.value = true
|
||||
initProgress()
|
||||
|
||||
const params = new URLSearchParams({
|
||||
accounts: selectedAccounts.value.join(','),
|
||||
days_back: String(daysBack.value),
|
||||
limit: String(limitPerAccount.value),
|
||||
mode: 'wide',
|
||||
})
|
||||
|
||||
useApiSSE(
|
||||
`/api/fetch/stream?${params}`,
|
||||
(data) => handleEvent(data as Record<string, unknown>),
|
||||
() => { fetching.value = false },
|
||||
() => { fetching.value = false },
|
||||
)
|
||||
}
|
||||
|
||||
function startTargetedFetch() {
|
||||
if (fetching.value || selectedAccounts.value.length === 0) return
|
||||
fetching.value = true
|
||||
initProgress()
|
||||
|
||||
const params = new URLSearchParams({
|
||||
accounts: selectedAccounts.value.join(','),
|
||||
days_back: String(daysBack.value),
|
||||
limit: String(limitPerAccount.value),
|
||||
mode: 'targeted',
|
||||
since: targetSince.value,
|
||||
before: targetBefore.value,
|
||||
term: targetTerm.value,
|
||||
field: targetField.value,
|
||||
})
|
||||
|
||||
useApiSSE(
|
||||
`/api/fetch/stream?${params}`,
|
||||
(data) => handleEvent(data as Record<string, unknown>),
|
||||
() => { fetching.value = false },
|
||||
() => { fetching.value = false },
|
||||
)
|
||||
}
|
||||
|
||||
function handleEvent(data: Record<string, unknown>) {
|
||||
const type = data.type as string
|
||||
const account = data.account as string | undefined
|
||||
|
||||
const row = account ? progress.value.find(p => p.account === account) : null
|
||||
|
||||
if (type === 'start' && row) {
|
||||
row.label = `0 / ${data.total_uids} found`
|
||||
row.pct = 2 // show a sliver immediately
|
||||
} else if (type === 'progress' && row) {
|
||||
const total = (data.total_uids as number) || 1
|
||||
const fetched = (data.fetched as number) || 0
|
||||
row.pct = Math.round((fetched / total) * 95)
|
||||
row.label = `${fetched} fetched…`
|
||||
} else if (type === 'done' && row) {
|
||||
row.pct = 100
|
||||
row.done = true
|
||||
row.label = `${data.added} added, ${data.skipped} skipped`
|
||||
} else if (type === 'error' && row) {
|
||||
row.error = true
|
||||
row.label = String(data.message || 'Error')
|
||||
} else if (type === 'complete') {
|
||||
completeMsg.value =
|
||||
`Done — ${data.total_added} new email(s) added · Queue: ${data.queue_size}`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadConfig)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fetch-view {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: var(--font-display, var(--font-body, sans-serif));
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--app-primary, #2A6080);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #6b7a99);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border, #d0d7e8);
|
||||
}
|
||||
|
||||
.account-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.field-inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
min-width: 120px;
|
||||
color: var(--color-text-secondary, #6b7a99);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
min-width: 32px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
accent-color: var(--app-primary, #2A6080);
|
||||
}
|
||||
|
||||
.field-num {
|
||||
width: 90px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.9rem;
|
||||
background: var(--color-surface, #fff);
|
||||
color: var(--color-text, #1a2338);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.6rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: var(--app-primary, #2A6080);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-family: var(--font-body, sans-serif);
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) { background: var(--app-primary-dark, #1d4d65); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-fetch { min-width: 200px; }
|
||||
|
||||
.progress-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
display: grid;
|
||||
grid-template-columns: 10rem 1fr 8rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.progress-name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 12px;
|
||||
background: var(--color-surface-raised, #e4ebf5);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--app-primary, #2A6080);
|
||||
border-radius: 99px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-fill.done { background: #4CAF50; }
|
||||
.progress-fill.error { background: var(--color-error, #ef4444); }
|
||||
|
||||
.progress-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary, #6b7a99);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.complete-msg {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #155724;
|
||||
background: #d4edda;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.targeted-section {
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.targeted-summary {
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--color-surface-raised, #e4ebf5);
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.targeted-fields {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-grow { flex: 1; }
|
||||
|
||||
.field select,
|
||||
.field input[type="date"],
|
||||
.field input[type="text"] {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-surface, #fff);
|
||||
color: var(--color-text, #1a2338);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.field-select { width: 100%; }
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.4rem 0.9rem;
|
||||
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;
|
||||
font-family: var(--font-body, sans-serif);
|
||||
align-self: flex-start;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover { background: var(--color-surface-raised, #e4ebf5); }
|
||||
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.targeted-note {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary, #6b7a99);
|
||||
}
|
||||
|
||||
.empty-notice {
|
||||
color: var(--color-text-secondary, #6b7a99);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--color-border, #d0d7e8);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--app-primary, #2A6080);
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import LabelView from './LabelView.vue'
|
||||
import EmailCardStack from '../components/EmailCardStack.vue'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock fetch globally
|
||||
|
|
@ -43,4 +44,49 @@ describe('LabelView', () => {
|
|||
expect(w.text()).toContain('Skip')
|
||||
expect(w.text()).toContain('Discard')
|
||||
})
|
||||
|
||||
const queueItem = {
|
||||
id: 'test-1', subject: 'Test Email', body: 'Test body',
|
||||
from: 'test@test.com', date: '2026-03-05', source: 'test',
|
||||
}
|
||||
|
||||
// Return queue items for /api/queue, empty array for /api/config/labels
|
||||
function mockFetchWithQueue() {
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => (url as string).includes('/api/queue')
|
||||
? { items: [queueItem], total: 1 }
|
||||
: [],
|
||||
text: async () => '',
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
it('renders toss zone overlays when isHeld is true (after drag-start)', async () => {
|
||||
mockFetchWithQueue()
|
||||
const w = mount(LabelView, { global: { plugins: [createPinia()] } })
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
await w.vm.$nextTick()
|
||||
// Zone overlays should not exist before drag
|
||||
expect(w.find('.toss-zone-left').exists()).toBe(false)
|
||||
// Emit drag-start from EmailCardStack child
|
||||
const cardStack = w.findComponent(EmailCardStack)
|
||||
cardStack.vm.$emit('drag-start')
|
||||
await w.vm.$nextTick()
|
||||
expect(w.find('.toss-zone-left').exists()).toBe(true)
|
||||
expect(w.find('.toss-zone-right').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('bucket-grid-footer has grid-active class while card is held', async () => {
|
||||
mockFetchWithQueue()
|
||||
const w = mount(LabelView, { global: { plugins: [createPinia()] } })
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
await w.vm.$nextTick()
|
||||
expect(w.find('.bucket-grid-footer').classes()).not.toContain('grid-active')
|
||||
const cardStack = w.findComponent(EmailCardStack)
|
||||
cardStack.vm.$emit('drag-start')
|
||||
await w.vm.$nextTick()
|
||||
expect(w.find('.bucket-grid-footer').classes()).toContain('grid-active')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
<!-- Header bar -->
|
||||
<header class="lv-header">
|
||||
<span class="queue-count">
|
||||
<template v-if="store.totalRemaining > 0">
|
||||
<span v-if="loading" class="queue-status">Loading…</span>
|
||||
<template v-else-if="store.totalRemaining > 0">
|
||||
{{ store.totalRemaining }} remaining
|
||||
</template>
|
||||
<span v-else class="queue-status">Queue empty</span>
|
||||
<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>
|
||||
|
|
@ -34,24 +36,47 @@
|
|||
|
||||
<!-- Card stack + label grid -->
|
||||
<template v-else>
|
||||
<!-- Toss edge zones — thin trip-wires at screen edges, visible only while card is held -->
|
||||
<Transition name="zone-fade">
|
||||
<div
|
||||
v-if="isHeld && motion.rich.value"
|
||||
class="toss-zone toss-zone-left"
|
||||
:class="{ active: hoveredZone === 'discard' }"
|
||||
aria-hidden="true"
|
||||
>✕</div>
|
||||
</Transition>
|
||||
<Transition name="zone-fade">
|
||||
<div
|
||||
v-if="isHeld && motion.rich.value"
|
||||
class="toss-zone toss-zone-right"
|
||||
:class="{ active: hoveredZone === 'skip' }"
|
||||
aria-hidden="true"
|
||||
>→</div>
|
||||
</Transition>
|
||||
|
||||
<div class="card-stack-wrapper">
|
||||
<EmailCardStack
|
||||
:item="store.current"
|
||||
:is-bucket-mode="isDragging"
|
||||
:is-bucket-mode="isHeld"
|
||||
:dismiss-type="dismissType"
|
||||
@label="handleLabel"
|
||||
@skip="handleSkip"
|
||||
@discard="handleDiscard"
|
||||
@drag-start="isDragging = true"
|
||||
@drag-end="isDragging = false"
|
||||
@drag-start="isHeld = true"
|
||||
@drag-end="isHeld = false"
|
||||
@zone-hover="hoveredZone = $event"
|
||||
@bucket-hover="hoveredBucket = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LabelBucketGrid
|
||||
:labels="labels"
|
||||
:is-bucket-mode="isDragging"
|
||||
@label="handleLabel"
|
||||
/>
|
||||
<div class="bucket-grid-footer" :class="{ 'grid-active': isHeld }">
|
||||
<LabelBucketGrid
|
||||
:labels="labels"
|
||||
:is-bucket-mode="isHeld"
|
||||
:hovered-bucket="hoveredBucket"
|
||||
@label="handleLabel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Undo toast -->
|
||||
|
|
@ -59,6 +84,7 @@
|
|||
v-if="store.lastAction"
|
||||
:action="store.lastAction"
|
||||
@undo="handleUndo"
|
||||
@expire="store.clearLastAction()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -81,7 +107,9 @@ const motion = useMotion() // only needed to pass to child — actual value u
|
|||
|
||||
const loading = ref(true)
|
||||
const apiError = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const isHeld = ref(false)
|
||||
const hoveredZone = ref<'discard' | 'skip' | null>(null)
|
||||
const hoveredBucket = ref<string | null>(null)
|
||||
const labels = ref<any[]>([])
|
||||
const dismissType = ref<'label' | 'skip' | 'discard' | null>(null)
|
||||
|
||||
|
|
@ -115,7 +143,10 @@ async function fetchBatch() {
|
|||
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 }
|
||||
if (error || !data) {
|
||||
apiError.value = true
|
||||
return
|
||||
}
|
||||
store.queue = data.items
|
||||
store.totalRemaining = data.total
|
||||
|
||||
|
|
@ -246,7 +277,7 @@ async function handleUndo() {
|
|||
}
|
||||
|
||||
useLabelKeyboard({
|
||||
labels: [], // will be updated after labels load — keyboard not active until queue loads
|
||||
labels: () => labels.value, // getter — evaluated on each keypress
|
||||
onLabel: handleLabel,
|
||||
onSkip: handleSkip,
|
||||
onDiscard: handleDiscard,
|
||||
|
|
@ -286,6 +317,11 @@ onUnmounted(() => {
|
|||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.queue-status {
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.lv-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -382,5 +418,51 @@ onUnmounted(() => {
|
|||
|
||||
.card-stack-wrapper {
|
||||
flex: 1;
|
||||
/* Give bottom breathing room so grid doesn't overlap content */
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Bucket grid stays pinned to the bottom of the viewport while the email card
|
||||
can be scrolled freely. "hired" (10th button) may clip on very small screens
|
||||
— that is intentional per design. */
|
||||
.bucket-grid-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--color-bg, var(--color-surface, #f0f4fc));
|
||||
padding: 0.5rem 0 0.75rem;
|
||||
z-index: 10;
|
||||
transition: transform 250ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
background 200ms ease;
|
||||
}
|
||||
.bucket-grid-footer.grid-active {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
/* ── Toss edge zones ── */
|
||||
.toss-zone {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 7%;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
opacity: 0.25;
|
||||
transition: opacity 200ms ease, background 200ms ease;
|
||||
}
|
||||
.toss-zone-left { left: 0; background: rgba(244, 67, 54, 0.12); color: #ef4444; }
|
||||
.toss-zone-right { right: 0; background: rgba(255, 152, 0, 0.12); color: #f97316; }
|
||||
.toss-zone.active {
|
||||
opacity: 0.85;
|
||||
background: color-mix(in srgb, currentColor 25%, transparent);
|
||||
}
|
||||
|
||||
/* Zone transition */
|
||||
.zone-fade-enter-active,
|
||||
.zone-fade-leave-active { transition: opacity 180ms ease; }
|
||||
.zone-fade-enter-from,
|
||||
.zone-fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
|
|
|||
431
web/src/views/SettingsView.vue
Normal file
431
web/src/views/SettingsView.vue
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
<template>
|
||||
<div class="settings-view">
|
||||
<h1 class="page-title">⚙️ Settings</h1>
|
||||
|
||||
<!-- IMAP Accounts -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">IMAP Accounts</h2>
|
||||
|
||||
<div v-if="accounts.length === 0" class="empty-notice">
|
||||
No accounts configured yet. Click <strong>➕ Add account</strong> to get started.
|
||||
</div>
|
||||
|
||||
<details
|
||||
v-for="(acc, i) in accounts"
|
||||
:key="i"
|
||||
class="account-panel"
|
||||
open
|
||||
>
|
||||
<summary class="account-summary">
|
||||
{{ acc.name || acc.username || `Account ${i + 1}` }}
|
||||
</summary>
|
||||
|
||||
<div class="account-fields">
|
||||
<label class="field">
|
||||
<span>Display name</span>
|
||||
<input v-model="acc.name" type="text" placeholder="e.g. Gmail Personal" />
|
||||
</label>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field field-grow">
|
||||
<span>IMAP host</span>
|
||||
<input v-model="acc.host" type="text" placeholder="imap.gmail.com" />
|
||||
</label>
|
||||
<label class="field field-short">
|
||||
<span>Port</span>
|
||||
<input v-model.number="acc.port" type="number" min="1" max="65535" />
|
||||
</label>
|
||||
<label class="field field-check">
|
||||
<span>SSL</span>
|
||||
<input v-model="acc.use_ssl" type="checkbox" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>Username</span>
|
||||
<input v-model="acc.username" type="text" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<div class="password-wrap">
|
||||
<input
|
||||
v-model="acc.password"
|
||||
:type="showPassword[i] ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button type="button" class="btn-icon" @click="togglePassword(i)">
|
||||
{{ showPassword[i] ? '🙈' : '👁' }}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field field-grow">
|
||||
<span>Folder</span>
|
||||
<input v-model="acc.folder" type="text" placeholder="INBOX" />
|
||||
</label>
|
||||
<label class="field field-short">
|
||||
<span>Days back</span>
|
||||
<input v-model.number="acc.days_back" type="number" min="1" max="3650" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="account-actions">
|
||||
<button class="btn-secondary" @click="testAccount(i)">🔌 Test connection</button>
|
||||
<button class="btn-danger" @click="removeAccount(i)">🗑 Remove</button>
|
||||
<span
|
||||
v-if="testResults[i]"
|
||||
class="test-result"
|
||||
:class="testResults[i]?.ok ? 'result-ok' : 'result-err'"
|
||||
>
|
||||
{{ testResults[i]?.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button class="btn-secondary btn-add" @click="addAccount">➕ Add account</button>
|
||||
</section>
|
||||
|
||||
<!-- Global settings -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Global</h2>
|
||||
<label class="field field-inline">
|
||||
<span>Max emails per account per fetch</span>
|
||||
<input v-model.number="maxPerAccount" type="number" min="10" max="2000" class="field-num" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Display settings -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Display</h2>
|
||||
<label class="field field-inline">
|
||||
<input v-model="richMotion" type="checkbox" @change="onMotionChange" />
|
||||
<span>Rich animations & haptic feedback</span>
|
||||
</label>
|
||||
<label class="field field-inline">
|
||||
<input v-model="keyHints" type="checkbox" @change="onKeyHintsChange" />
|
||||
<span>Show keyboard shortcut hints on label buttons</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Save / Reload -->
|
||||
<div class="save-bar">
|
||||
<button class="btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving…' : '💾 Save' }}
|
||||
</button>
|
||||
<button class="btn-secondary" @click="reload">↩ Reload from disk</button>
|
||||
<span v-if="saveMsg" class="save-msg" :class="saveOk ? 'msg-ok' : 'msg-err'">
|
||||
{{ saveMsg }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
|
||||
interface Account {
|
||||
name: string; host: string; port: number; use_ssl: boolean
|
||||
username: string; password: string; folder: string; days_back: number
|
||||
}
|
||||
|
||||
const accounts = ref<Account[]>([])
|
||||
const maxPerAccount = ref(500)
|
||||
const showPassword = ref<boolean[]>([])
|
||||
const testResults = ref<Array<{ ok: boolean; message: string } | null>>([])
|
||||
const saving = ref(false)
|
||||
const saveMsg = ref('')
|
||||
const saveOk = ref(true)
|
||||
const richMotion = ref(localStorage.getItem('cf-avocet-rich-motion') !== 'false')
|
||||
const keyHints = ref(localStorage.getItem('cf-avocet-key-hints') !== 'false')
|
||||
|
||||
async function reload() {
|
||||
const { data } = await useApiFetch<{ accounts: Account[]; max_per_account: number }>('/api/config')
|
||||
if (data) {
|
||||
accounts.value = data.accounts
|
||||
maxPerAccount.value = data.max_per_account
|
||||
showPassword.value = new Array(data.accounts.length).fill(false)
|
||||
testResults.value = new Array(data.accounts.length).fill(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saveMsg.value = ''
|
||||
const { error } = await useApiFetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accounts: accounts.value, max_per_account: maxPerAccount.value }),
|
||||
})
|
||||
saving.value = false
|
||||
if (error) {
|
||||
saveOk.value = false
|
||||
saveMsg.value = '✗ Save failed'
|
||||
} else {
|
||||
saveOk.value = true
|
||||
saveMsg.value = '✓ Saved'
|
||||
setTimeout(() => { saveMsg.value = '' }, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
async function testAccount(i: number) {
|
||||
testResults.value[i] = null
|
||||
const { data } = await useApiFetch<{ ok: boolean; message: string; count: number | null }>(
|
||||
'/api/accounts/test',
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ account: accounts.value[i] }) },
|
||||
)
|
||||
if (data) {
|
||||
testResults.value[i] = { ok: data.ok, message: data.message }
|
||||
// Easter egg: > 5000 messages
|
||||
if (data.ok && data.count !== null && data.count > 5000) {
|
||||
setTimeout(() => {
|
||||
if (testResults.value[i]?.ok) {
|
||||
testResults.value[i] = { ok: true, message: `${data.message} That's a lot of email 📬` }
|
||||
}
|
||||
}, 800)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addAccount() {
|
||||
accounts.value.push({
|
||||
name: '', host: 'imap.gmail.com', port: 993, use_ssl: true,
|
||||
username: '', password: '', folder: 'INBOX', days_back: 90,
|
||||
})
|
||||
showPassword.value.push(false)
|
||||
testResults.value.push(null)
|
||||
}
|
||||
|
||||
function removeAccount(i: number) {
|
||||
accounts.value.splice(i, 1)
|
||||
showPassword.value.splice(i, 1)
|
||||
testResults.value.splice(i, 1)
|
||||
}
|
||||
|
||||
function togglePassword(i: number) {
|
||||
showPassword.value[i] = !showPassword.value[i]
|
||||
}
|
||||
|
||||
function onMotionChange() {
|
||||
localStorage.setItem('cf-avocet-rich-motion', String(richMotion.value))
|
||||
}
|
||||
|
||||
function onKeyHintsChange() {
|
||||
localStorage.setItem('cf-avocet-key-hints', String(keyHints.value))
|
||||
document.documentElement.classList.toggle('hide-key-hints', !keyHints.value)
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-view {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: var(--font-display, var(--font-body, sans-serif));
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--app-primary, #2A6080);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #1a2338);
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--color-border, #d0d7e8);
|
||||
}
|
||||
|
||||
.account-panel {
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.account-summary {
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--color-surface-raised, #e4ebf5);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.account-fields {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.field span:first-child {
|
||||
color: var(--color-text-secondary, #6b7a99);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.field input[type="text"],
|
||||
.field input[type="password"],
|
||||
.field input[type="number"] {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-surface, #fff);
|
||||
color: var(--color-text, #1a2338);
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font-body, sans-serif);
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.field-grow { flex: 1; }
|
||||
.field-short { width: 80px; }
|
||||
.field-check { width: 48px; align-items: center; }
|
||||
|
||||
.field-inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-num {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.password-wrap {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.password-wrap input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
border: 1px solid var(--color-border, #d0d7e8);
|
||||
background: transparent;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.account-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.result-ok { background: #d4edda; color: #155724; }
|
||||
.result-err { background: #f8d7da; color: #721c24; }
|
||||
|
||||
.btn-add { margin-top: 0.25rem; }
|
||||
|
||||
.btn-primary, .btn-secondary, .btn-danger {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid;
|
||||
font-family: var(--font-body, sans-serif);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border-color: var(--app-primary, #2A6080);
|
||||
background: var(--app-primary, #2A6080);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--app-primary-dark, #1d4d65);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
border-color: var(--color-border, #d0d7e8);
|
||||
background: var(--color-surface, #fff);
|
||||
color: var(--color-text, #1a2338);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-surface-raised, #e4ebf5);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: var(--color-error, #ef4444);
|
||||
background: transparent;
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.save-msg {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.msg-ok { background: #d4edda; color: #155724; }
|
||||
.msg-err { background: #f8d7da; color: #721c24; }
|
||||
|
||||
.empty-notice {
|
||||
color: var(--color-text-secondary, #6b7a99);
|
||||
font-size: 0.9rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed var(--color-border, #d0d7e8);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
245
web/src/views/StatsView.vue
Normal file
245
web/src/views/StatsView.vue
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
<template>
|
||||
<div class="stats-view">
|
||||
<h1 class="page-title">📊 Statistics</h1>
|
||||
|
||||
<div v-if="loading" class="loading">Loading…</div>
|
||||
|
||||
<div v-else-if="error" class="error-notice" role="alert">
|
||||
{{ error }} <button class="btn-secondary" @click="load">Retry</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p class="total-count">
|
||||
<strong>{{ stats.total.toLocaleString() }}</strong> emails labeled total
|
||||
</p>
|
||||
|
||||
<div v-if="stats.total === 0" class="empty-notice">
|
||||
No labeled emails yet — go to <strong>Label</strong> to start labeling.
|
||||
</div>
|
||||
|
||||
<div v-else class="label-bars">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.name"
|
||||
class="bar-row"
|
||||
>
|
||||
<span class="bar-emoji" aria-hidden="true">{{ row.emoji }}</span>
|
||||
<span class="bar-label">{{ row.name.replace(/_/g, '\u00a0') }}</span>
|
||||
<div class="bar-track" :title="`${row.count} (${row.pct}%)`">
|
||||
<div
|
||||
class="bar-fill"
|
||||
:style="{ width: `${row.pct}%`, background: row.color }"
|
||||
/>
|
||||
</div>
|
||||
<span class="bar-count">{{ row.count.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<span class="file-path">Score file: <code>data/email_score.jsonl</code></span>
|
||||
<span class="file-size">{{ fileSizeLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="action-bar">
|
||||
<button class="btn-secondary" @click="load">🔄 Refresh</button>
|
||||
<a class="btn-secondary" href="/api/stats/download" download="email_score.jsonl">
|
||||
⬇️ Download
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
|
||||
interface StatsResponse {
|
||||
total: number
|
||||
counts: Record<string, number>
|
||||
score_file_bytes: number
|
||||
}
|
||||
|
||||
// Canonical label order + metadata
|
||||
const LABEL_META: Record<string, { emoji: string; color: string }> = {
|
||||
interview_scheduled: { emoji: '🗓️', color: '#4CAF50' },
|
||||
offer_received: { emoji: '🎉', color: '#2196F3' },
|
||||
rejected: { emoji: '❌', color: '#F44336' },
|
||||
positive_response: { emoji: '👍', color: '#FF9800' },
|
||||
survey_received: { emoji: '📋', color: '#9C27B0' },
|
||||
neutral: { emoji: '⬜', color: '#607D8B' },
|
||||
event_rescheduled: { emoji: '🔄', color: '#FF5722' },
|
||||
digest: { emoji: '📰', color: '#00BCD4' },
|
||||
new_lead: { emoji: '🤝', color: '#009688' },
|
||||
hired: { emoji: '🎊', color: '#FFC107' },
|
||||
}
|
||||
|
||||
const CANONICAL_ORDER = Object.keys(LABEL_META)
|
||||
|
||||
const stats = ref<StatsResponse>({ total: 0, counts: {}, score_file_bytes: 0 })
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const rows = computed(() => {
|
||||
const max = Math.max(...Object.values(stats.value.counts), 1)
|
||||
const allLabels = [
|
||||
...CANONICAL_ORDER,
|
||||
...Object.keys(stats.value.counts).filter(k => !CANONICAL_ORDER.includes(k)),
|
||||
].filter(k => stats.value.counts[k] > 0)
|
||||
|
||||
return allLabels.map(name => {
|
||||
const count = stats.value.counts[name] ?? 0
|
||||
const meta = LABEL_META[name] ?? { emoji: '🏷️', color: '#607D8B' }
|
||||
return {
|
||||
name,
|
||||
count,
|
||||
emoji: meta.emoji,
|
||||
color: meta.color,
|
||||
pct: Math.round((count / max) * 100),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const fileSizeLabel = computed(() => {
|
||||
const b = stats.value.score_file_bytes
|
||||
if (b === 0) return '(file not found)'
|
||||
if (b < 1024) return `${b} B`
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
|
||||
return `${(b / 1024 / 1024).toFixed(2)} MB`
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const { data, error: err } = await useApiFetch<StatsResponse>('/api/stats')
|
||||
loading.value = false
|
||||
if (err || !data) {
|
||||
error.value = 'Could not reach Avocet API.'
|
||||
} else {
|
||||
stats.value = data
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stats-view {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: var(--font-display, var(--font-body, sans-serif));
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--app-primary, #2A6080);
|
||||
}
|
||||
|
||||
.total-count {
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary, #6b7a99);
|
||||
}
|
||||
|
||||
.label-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5rem 11rem 1fr 3.5rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.bar-emoji { text-align: center; }
|
||||
|
||||
.bar-label {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text, #1a2338);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
height: 14px;
|
||||
background: var(--color-surface-raised, #e4ebf5);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.bar-count {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary, #6b7a99);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary, #6b7a99);
|
||||
}
|
||||
|
||||
.file-path code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
background: var(--color-surface-raised, #e4ebf5);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.4rem 0.9rem;
|
||||
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;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-body, sans-serif);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-surface-raised, #e4ebf5);
|
||||
}
|
||||
|
||||
.loading, .error-notice, .empty-notice {
|
||||
color: var(--color-text-secondary, #6b7a99);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bar-row {
|
||||
grid-template-columns: 1.5rem 1fr 1fr 3rem;
|
||||
}
|
||||
.bar-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue