8.9 KiB
Signal Banner Redesign — Expandable Email + Re-classification
Date: 2026-03-19 Status: Approved — ready for implementation planning
Goal
Improve the stage signal banners added in the interviews improvements feature by:
- Allowing users to expand a banner to read the full email body before acting
- Providing inline re-classification chips to correct inaccurate classifier labels
- Removing the implicit one-click stage advancement risk —
[→ Move]remains but always routes through MoveToSheet confirmation
Decisions Made
| Decision | Choice |
|---|---|
| Email body loading | Eager — add body and from_addr to GET /api/interviews signal query |
| Neutral/excluded re-classification | Optimistic dismiss (same path as ✕) |
| Re-classification persistence | Update job_contacts.stage_signal in place; no separate correction record |
| Avocet training integration | Deferred — reclassify endpoint is the hook; export logic added later |
| Expand state persistence | None — local component state only, resets on page reload |
[→ Move] button rename |
No rename — pre-selection hint is still useful; MoveToSheet confirm is the safeguard |
Data Model Changes
StageSignal interface (add two fields)
export interface StageSignal {
id: number
subject: string
received_at: string
stage_signal: 'interview_scheduled' | 'positive_response' | 'offer_received' | 'survey_received' | 'rejected'
body: string | null // NEW — email body text
from_addr: string | null // NEW — sender address
}
GET /api/interviews — signal query additions
Add body, from_addr to the SELECT clause of the second query in list_interviews():
SELECT id, job_id, subject, received_at, stage_signal, body, from_addr
FROM job_contacts
WHERE job_id IN (:job_ids)
AND suggestion_dismissed = 0
AND stage_signal NOT IN ('neutral', 'unrelated', 'digest', 'event_rescheduled')
AND stage_signal IS NOT NULL
ORDER BY received_at DESC
The Python grouping dict append also includes the new fields:
signals_by_job[sr["job_id"]].append({
"id": sr["id"],
"subject": sr["subject"],
"received_at": sr["received_at"],
"stage_signal": sr["stage_signal"],
"body": sr["body"],
"from_addr": sr["from_addr"],
})
New Endpoint
POST /api/stage-signals/{id}/reclassify
POST /api/stage-signals/{id}/reclassify
Body: { stage_signal: string }
→ 200 { ok: true }
→ 400 if stage_signal is not a valid label
→ 404 if signal not found
Updates job_contacts.stage_signal for the given row. Valid labels are the nine classifier labels: interview_scheduled, offer_received, rejected, positive_response, survey_received, neutral, event_rescheduled, unrelated, digest.
Implementation:
VALID_SIGNAL_LABELS = {
'interview_scheduled', 'offer_received', 'rejected',
'positive_response', 'survey_received', 'neutral',
'event_rescheduled', 'unrelated', 'digest',
}
@app.post("/api/stage-signals/{signal_id}/reclassify")
def reclassify_signal(signal_id: int, body: ReclassifyBody):
if body.stage_signal not in VALID_SIGNAL_LABELS:
raise HTTPException(400, f"Invalid label: {body.stage_signal}")
db = _get_db()
result = db.execute(
"UPDATE job_contacts SET stage_signal = ? WHERE id = ?",
(body.stage_signal, signal_id),
)
rowcount = result.rowcount
db.commit()
db.close()
if rowcount == 0:
raise HTTPException(404, "Signal not found")
return {"ok": True}
With Pydantic model:
class ReclassifyBody(BaseModel):
stage_signal: str
Banner UI Redesign
Layout — collapsed (default)
┌──────────────────────────────────────────────────┐
│ [existing card / row content] │
│──────────────────────────────────────────────────│ ← colored top border
│ 📧 Interview scheduled "Interview confirmed…" │
│ [▸ Read] [→ Move] [✕] │
└──────────────────────────────────────────────────┘
- Subject line: signal type label + subject snippet (truncated to ~60 chars)
[▸ Read]— expand toggle (text button, no border)[→ Move]— opens MoveToSheet withpreSelectedStagebased on current signal type (unchanged from previous implementation)[✕]— dismiss
Layout — expanded
┌──────────────────────────────────────────────────┐
│ [existing card / row content] │
│──────────────────────────────────────────────────│
│ 📧 Interview scheduled "Interview confirmed…" │
│ [▾ Hide] [→ Move] [✕] │
│ From: recruiter@acme.com │
│ "Hi, we'd like to schedule an interview │
│ for Tuesday at 2pm…" │
│ Re-classify: [🟡 Interview ✓] [🟢 Offer] │
│ [✅ Positive] [📋 Survey] │
│ [✖ Rejected] [— Neutral] │
└──────────────────────────────────────────────────┘
[▾ Hide]— collapses back to default- Email body: full text, not truncated, in a
pre-wrapstyle block From:line shown iffrom_addris non-null- Re-classify chips: one per actionable type + neutral (see chip spec below)
Re-classify chips
| Label | Display | Action on click |
|---|---|---|
interview_scheduled |
🟡 Interview | Set as active label |
positive_response |
✅ Positive | Set as active label |
offer_received |
🟢 Offer | Set as active label |
survey_received |
📋 Survey | Set as active label |
rejected |
✖ Rejected | Set as active label |
neutral |
— Neutral | Optimistic dismiss (remove from local array) |
The chip matching the current stage_signal is highlighted (active state). Clicking a non-neutral chip:
- Optimistically updates
sig.stage_signalon the local signal object - Banner accent color, action label, and
[→ Move]preSelectedStage update reactively POST /api/stage-signals/{id}/reclassifyfires in background
Clicking Neutral:
- Optimistically removes the signal from the local
stage_signalsarray (same as dismiss) POST /api/stage-signals/{id}/dismissfires in background (reuses existing dismiss endpoint — no need to store a "neutral" label, since dismissed = no longer shown)
Reactive re-labeling
When sig.stage_signal changes locally (after a chip click), the banner updates immediately:
- Accent color (
amber/green/red) fromSIGNAL_META[sig.stage_signal].color - Action label in
[→ Move]pre-selection fromSIGNAL_META[sig.stage_signal].stage - Active chip highlight moves to the new label
This works because sig is a reactive object from the Pinia store — property mutations trigger re-render.
Expand state
sigBodyExpanded: boolean — local ref per banner instance (per sig.id). Not persisted.
In InterviewCard.vue: one ref per card instance (bodyExpanded = ref(false)), since each card shows at most one visible signal at a time (the others behind +N more).
In InterviewsView.vue pre-list: keyed by signal id using a Map<number, boolean> ref, since multiple jobs × multiple signals can be expanded simultaneously.
Files
| File | Action |
|---|---|
dev-api.py |
Add body, from_addr to signal SELECT; add POST /api/stage-signals/{id}/reclassify |
tests/test_dev_api_interviews.py |
Add tests for reclassify endpoint; verify body/from_addr in interviews response |
web/src/stores/interviews.ts |
Add body: string | null, from_addr: string | null to StageSignal |
web/src/components/InterviewCard.vue |
Expand toggle; body/from display; re-classify chips; reactive local re-label |
web/src/views/InterviewsView.vue |
Same for pre-list banner rows (keyed expand map) |
What Stays the Same
- Dismiss endpoint and optimistic removal — unchanged
[→ Move]button routing through MoveToSheet with preSelectedStage — unchanged+N more/− lessmulti-signal expand — unchanged- Signal banner placement (InterviewCard kanban + InterviewsView pre-list) — unchanged
- Signal → stage → color mapping (
SIGNAL_META/SIGNAL_META_PRE) — unchanged (but now reactive to re-classification) ⚡ N signalscount in Applied section header — unchanged