peregrine/docs/superpowers/plans/2026-03-19-interviews-improvements.md

42 KiB
Raw Blame History

Interviews Page Improvements — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add three improvements to the Interviews page: (1) collapsible Applied/Survey pre-kanban section with localStorage persistence, (2) email sync status pill in the page header, (3) stage signal banners on job cards in both the pre-list and the kanban.

Architecture: Backend adds four new endpoints to dev-api.py (stage signals batched into GET /api/interviews, email sync trigger/status, signal dismiss). The store gets a new exported StageSignal type. MoveToSheet gains an optional preSelectedStage prop. InterviewCard gains the signal banner and an extended move emit. InterviewsView gets the collapsible section and email sync pill — both wired together.

Tech Stack: Python FastAPI (dev-api.py), Vue 3, TypeScript, Pinia, CSS max-height transition


File Map

File Action
dev-api.py Stage signals in /api/interviews; POST /api/email/sync; GET /api/email/sync/status; POST /api/stage-signals/{id}/dismiss
tests/test_dev_api_interviews.py NEW — pytest tests for all four new dev-api behaviors
web/src/stores/interviews.ts Export StageSignal interface; add stage_signals: StageSignal[] to PipelineJob; update fetchAll() identity map
web/src/components/MoveToSheet.vue Add optional preSelectedStage?: PipelineStage prop; pre-select on open
web/src/components/InterviewCard.vue Signal banner at card bottom; extend move emit signature
web/src/views/InterviewsView.vue Collapsible Applied section (localStorage, max-height CSS, signal count in header); email sync pill + polling; wire preSelectedStage through openMoveMoveToSheet

Task 1: Backend — new dev-api.py endpoints

Files:

  • Modify: dev-api.py
  • Create: tests/test_dev_api_interviews.py

Context

list_interviews() (line 286) currently runs one query then closes the DB. We'll refactor it to keep the connection open, run a second query for undismissed signals, group results by job_id in Python, then close. The three new endpoints follow the existing _get_db() + db.close() pattern. SQLite column is finished_at (NOT completed_at) in background_tasks. Use job_id = 0 as sentinel for global email sync tasks.

Signal types to exclude from the query: 'neutral', 'unrelated', 'digest', 'event_rescheduled'.

  • Step 1: Write the failing tests

Create tests/test_dev_api_interviews.py:

"""Tests for new dev-api.py endpoints: stage signals, email sync, signal dismiss."""
import sqlite3
import tempfile
import os
import pytest
from fastapi.testclient import TestClient


@pytest.fixture()
def tmp_db(tmp_path):
    """Create a minimal staging.db schema in a temp dir."""
    db_path = str(tmp_path / "staging.db")
    con = sqlite3.connect(db_path)
    con.executescript("""
        CREATE TABLE jobs (
            id INTEGER PRIMARY KEY,
            title TEXT, company TEXT, url TEXT, location TEXT,
            is_remote INTEGER DEFAULT 0, salary TEXT,
            match_score REAL, keyword_gaps TEXT, status TEXT,
            interview_date TEXT, rejection_stage TEXT,
            applied_at TEXT, phone_screen_at TEXT, interviewing_at TEXT,
            offer_at TEXT, hired_at TEXT, survey_at TEXT
        );
        CREATE TABLE job_contacts (
            id INTEGER PRIMARY KEY,
            job_id INTEGER,
            subject TEXT,
            received_at TEXT,
            stage_signal TEXT,
            suggestion_dismissed INTEGER DEFAULT 0
        );
        CREATE TABLE background_tasks (
            id INTEGER PRIMARY KEY,
            task_type TEXT,
            job_id INTEGER,
            status TEXT DEFAULT 'queued',
            finished_at TEXT
        );
        INSERT INTO jobs (id, title, company, status) VALUES
            (1, 'Engineer', 'Acme', 'applied'),
            (2, 'Designer', 'Beta', 'phone_screen');
        INSERT INTO job_contacts (id, job_id, subject, received_at, stage_signal, suggestion_dismissed) VALUES
            (10, 1, 'Interview confirmed', '2026-03-19T10:00:00', 'interview_scheduled', 0),
            (11, 1, 'Old neutral', '2026-03-18T09:00:00', 'neutral', 0),
            (12, 2, 'Offer letter', '2026-03-19T11:00:00', 'offer_received', 0),
            (13, 1, 'Already dismissed', '2026-03-17T08:00:00', 'positive_response', 1);
    """)
    con.close()
    return db_path


@pytest.fixture()
def client(tmp_db, monkeypatch):
    monkeypatch.setenv("STAGING_DB", tmp_db)
    # Re-import after env var is set so DB_PATH picks it up
    import importlib
    import dev_api
    importlib.reload(dev_api)
    return TestClient(dev_api.app)


# ── GET /api/interviews — stage signals batched ────────────────────────────

def test_interviews_includes_stage_signals(client):
    resp = client.get("/api/interviews")
    assert resp.status_code == 200
    jobs = {j["id"]: j for j in resp.json()}

    # job 1 should have exactly 1 undismissed non-excluded signal
    assert "stage_signals" in jobs[1]
    signals = jobs[1]["stage_signals"]
    assert len(signals) == 1
    assert signals[0]["stage_signal"] == "interview_scheduled"
    assert signals[0]["subject"] == "Interview confirmed"
    assert signals[0]["id"] == 10

    # neutral signal excluded
    signal_types = [s["stage_signal"] for s in signals]
    assert "neutral" not in signal_types

    # dismissed signal excluded
    signal_ids = [s["id"] for s in signals]
    assert 13 not in signal_ids

    # job 2 has an offer signal
    assert len(jobs[2]["stage_signals"]) == 1
    assert jobs[2]["stage_signals"][0]["stage_signal"] == "offer_received"


def test_interviews_empty_signals_for_job_without_contacts(client, tmp_db):
    con = sqlite3.connect(tmp_db)
    con.execute("INSERT INTO jobs (id, title, company, status) VALUES (3, 'NoContact', 'Corp', 'survey')")
    con.commit(); con.close()
    resp = client.get("/api/interviews")
    jobs = {j["id"]: j for j in resp.json()}
    assert jobs[3]["stage_signals"] == []


# ── POST /api/email/sync ───────────────────────────────────────────────────

def test_email_sync_returns_202(client):
    resp = client.post("/api/email/sync")
    assert resp.status_code == 202
    assert "task_id" in resp.json()


def test_email_sync_inserts_background_task(client, tmp_db):
    client.post("/api/email/sync")
    con = sqlite3.connect(tmp_db)
    row = con.execute(
        "SELECT task_type, job_id, status FROM background_tasks WHERE task_type='email_sync'"
    ).fetchone()
    con.close()
    assert row is not None
    assert row[0] == "email_sync"
    assert row[1] == 0   # sentinel
    assert row[2] == "queued"


# ── GET /api/email/sync/status ─────────────────────────────────────────────

def test_email_sync_status_idle_when_no_tasks(client):
    resp = client.get("/api/email/sync/status")
    assert resp.status_code == 200
    body = resp.json()
    assert body["status"] == "idle"
    assert body["last_completed_at"] is None


def test_email_sync_status_reflects_latest_task(client, tmp_db):
    con = sqlite3.connect(tmp_db)
    con.execute(
        "INSERT INTO background_tasks (task_type, job_id, status, finished_at) VALUES "
        "('email_sync', 0, 'completed', '2026-03-19T12:00:00')"
    )
    con.commit(); con.close()
    resp = client.get("/api/email/sync/status")
    body = resp.json()
    assert body["status"] == "completed"
    assert body["last_completed_at"] == "2026-03-19T12:00:00"


# ── POST /api/stage-signals/{id}/dismiss ──────────────────────────────────

def test_dismiss_signal_sets_flag(client, tmp_db):
    resp = client.post("/api/stage-signals/10/dismiss")
    assert resp.status_code == 200
    assert resp.json() == {"ok": True}
    con = sqlite3.connect(tmp_db)
    row = con.execute(
        "SELECT suggestion_dismissed FROM job_contacts WHERE id = 10"
    ).fetchone()
    con.close()
    assert row[0] == 1


def test_dismiss_signal_404_for_missing_id(client):
    resp = client.post("/api/stage-signals/9999/dismiss")
    assert resp.status_code == 404
  • Step 2: Run tests to verify they fail
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_interviews.py -v

Expected: FAIL — dev_api module not found (tests reference dev_api not dev-api).

  • Step 3: Create a dev_api.py symlink (module alias)

The test imports dev_api (underscore). Create a thin module alias:

cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
ln -sf dev-api.py dev_api.py

Re-run — should now fail with ImportError or route-not-found errors (not import error), which confirms the test infrastructure works.

  • Step 4: Implement the four backend changes in dev-api.py

4a — Stage signals in list_interviews(): Replace lines 286301 with:

SIGNAL_EXCLUDED = ("neutral", "unrelated", "digest", "event_rescheduled")

@app.get("/api/interviews")
def list_interviews():
    db = _get_db()
    placeholders = ",".join("?" * len(PIPELINE_STATUSES))
    rows = db.execute(
        f"SELECT id, title, company, url, location, is_remote, salary, "
        f"match_score, keyword_gaps, status, "
        f"interview_date, rejection_stage, "
        f"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, survey_at "
        f"FROM jobs WHERE status IN ({placeholders}) "
        f"ORDER BY match_score DESC NULLS LAST",
        list(PIPELINE_STATUSES),
    ).fetchall()

    job_ids = [r["id"] for r in rows]
    signals_by_job: dict[int, list] = {r["id"]: [] for r in rows}

    if job_ids:
        sig_placeholders = ",".join("?" * len(job_ids))
        excl_placeholders = ",".join("?" * len(SIGNAL_EXCLUDED))
        sig_rows = db.execute(
            f"SELECT id, job_id, subject, received_at, stage_signal "
            f"FROM job_contacts "
            f"WHERE job_id IN ({sig_placeholders}) "
            f"  AND suggestion_dismissed = 0 "
            f"  AND stage_signal NOT IN ({excl_placeholders}) "
            f"  AND stage_signal IS NOT NULL "
            f"ORDER BY received_at DESC",
            job_ids + list(SIGNAL_EXCLUDED),
        ).fetchall()
        for sr in sig_rows:
            signals_by_job[sr["job_id"]].append({
                "id":           sr["id"],
                "subject":      sr["subject"],
                "received_at":  sr["received_at"],
                "stage_signal": sr["stage_signal"],
            })

    db.close()
    return [
        {**dict(r), "is_remote": bool(r["is_remote"]), "stage_signals": signals_by_job[r["id"]]}
        for r in rows
    ]

4b — Email sync endpoints: Add after the list_interviews function (before the POST /api/jobs/{id}/move block):

# ── POST /api/email/sync ──────────────────────────────────────────────────

@app.post("/api/email/sync", status_code=202)
def trigger_email_sync():
    db = _get_db()
    db.execute(
        "INSERT INTO background_tasks (task_type, job_id, status) VALUES ('email_sync', 0, 'queued')"
    )
    db.commit()
    task_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
    db.close()
    return {"task_id": task_id}


# ── GET /api/email/sync/status ────────────────────────────────────────────

@app.get("/api/email/sync/status")
def email_sync_status():
    db = _get_db()
    row = db.execute(
        "SELECT status, finished_at AS last_completed_at, error "
        "FROM background_tasks "
        "WHERE task_type = 'email_sync' "
        "ORDER BY id DESC LIMIT 1"
    ).fetchone()
    db.close()
    if row is None:
        return {"status": "idle", "last_completed_at": None, "error": None}
    # background_tasks may not have an error column in staging — guard with dict access
    row_dict = dict(row)
    return {
        "status":           row_dict["status"],
        "last_completed_at": row_dict["last_completed_at"],
        "error":            row_dict.get("error"),
    }


# ── POST /api/stage-signals/{id}/dismiss ─────────────────────────────────

@app.post("/api/stage-signals/{signal_id}/dismiss")
def dismiss_signal(signal_id: int):
    db = _get_db()
    result = db.execute(
        "UPDATE job_contacts SET suggestion_dismissed = 1 WHERE id = ?",
        (signal_id,),
    )
    db.commit()
    db.close()
    if result.rowcount == 0:
        raise HTTPException(404, "Signal not found")
    return {"ok": True}
  • Step 5: Run tests again — verify they pass
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_interviews.py -v

Expected: all tests PASS.

  • Step 6: Run the full test suite to check for regressions
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v --ignore=tests/e2e

Expected: existing tests still pass.

  • Step 7: Commit

Note: dev_api.py is a symlink committed to the repo so that pytest can import the dev_api module by its Python-valid name. It points to dev-api.py (which uses a hyphen and is not directly importable). This is intentional.

cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add dev-api.py dev_api.py tests/test_dev_api_interviews.py
git commit -m "feat(interviews): add stage signals, email sync, and dismiss endpoints to dev-api"

Task 2: Store — StageSignal type + PipelineJob update

Files:

  • Modify: web/src/stores/interviews.ts

Context

Add and export the StageSignal interface before PipelineJob. Add stage_signals: StageSignal[] to PipelineJob. The fetchAll() function already maps data with { ...j } — since the API now returns stage_signals, it will be included automatically. No other logic changes.

  • Step 1: Add the StageSignal export and update PipelineJob

In web/src/stores/interviews.ts, insert the StageSignal interface before the PipelineJob interface and add stage_signals to PipelineJob:

// ADD before PipelineJob:
export interface StageSignal {
  id: number              // job_contacts.id — used for POST /api/stage-signals/{id}/dismiss
  subject: string
  received_at: string     // ISO timestamp
  stage_signal: 'interview_scheduled' | 'positive_response' | 'offer_received' | 'survey_received' | 'rejected'
}

// MODIFY PipelineJob — add as last field:
  stage_signals: StageSignal[]  // undismissed signals, newest first
  • Step 2: Type-check
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npx vue-tsc --noEmit

Expected: 0 errors.

  • Step 3: Commit
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add web/src/stores/interviews.ts
git commit -m "feat(interviews): export StageSignal interface; add stage_signals to PipelineJob"

Task 3: MoveToSheet — preSelectedStage prop

Files:

  • Modify: web/src/components/MoveToSheet.vue

Context

MoveToSheet currently initializes selectedStage to null. Adding an optional preSelectedStage prop means: if it's provided, selectedStage starts with that value (the stage button appears pre-highlighted). The prop is typed PipelineStage | undefined and defaults to undefined. All existing non-signal openMove() calls pass no preSelectedStage, so the sheet defaults to null-selected as before.

  • Step 1: Add the preSelectedStage optional prop

Open web/src/components/MoveToSheet.vue. The file currently has a bare defineProps<{...}>() call with no withDefaults. Simply add the optional field — no withDefaults wrapper needed since optional props default to undefined in Vue 3.

// BEFORE (lines 69):
const props = defineProps<{
  currentStatus: string
  jobTitle:      string
}>()

// AFTER:
const props = defineProps<{
  currentStatus:     string
  jobTitle:          string
  preSelectedStage?: PipelineStage
}>()

PipelineStage is already imported on line 4 (import type { PipelineStage } from '../stores/interviews') — no new import needed.

  • Step 2: Pre-select the stage on mount

Replace the current selectedStage initialization (line 16):

// BEFORE:
const selectedStage = ref<PipelineStage | null>(null)

// AFTER:
const selectedStage = ref<PipelineStage | null>(props.preSelectedStage ?? null)
  • Step 3: Type-check
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npx vue-tsc --noEmit

Expected: 0 errors.

  • Step 4: Commit
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add web/src/components/MoveToSheet.vue
git commit -m "feat(interviews): add preSelectedStage prop to MoveToSheet"

Task 4: InterviewCard — signal banner + move emit extension

Files:

  • Modify: web/src/components/InterviewCard.vue

Context

The card currently ends at its </div> close with no signal section. We add a signal banner block inside the card border (after existing content). The move emit is extended from move: [jobId: number] to move: [jobId: number, preSelectedStage?: PipelineStage] — the second arg is optional so existing @move="openMove" usages remain valid.

StageSignal and PipelineStage are imported from the store. The job prop already comes in as PipelineJob (post-Task 2 it now includes stage_signals).

Signal → stage mapping (must be hardcoded; rejected'interview_rejected', not 'rejected'):

interview_scheduled  → phone_screen,   amber,  "Move to Phone Screen"
positive_response    → phone_screen,   amber,  "Move to Phone Screen"
offer_received       → offer,          green,  "Move to Offer"
survey_received      → survey,         amber,  "Move to Survey"
rejected             → interview_rejected, red, "Mark Rejected"

Multiple signals: when stage_signals.length > 1, only the most recent banner shows. A +N more link below it toggles showing all signals. sigExpanded ref tracks this state.

Dismiss: optimistic — remove from local job.stage_signals array immediately, then POST /api/stage-signals/{id}/dismiss. No error recovery needed (optimistic per spec).

overflow: hidden on .interview-card must not clip the banner. Remove that rule — the card has a border already so there's no visual change. (The card currently has overflow: hidden which would hide the bottom banner border-radius.)

  • Step 1: Add signal helpers to the <script setup> block
// Update the existing vue import line to include `ref` if not already present:
import { ref, computed } from 'vue'
// (InterviewCard currently imports ref — verify it's there; add if missing)

import type { StageSignal, PipelineStage } from '../stores/interviews'
import { useApiFetch } from '../composables/useApi'

// Add to emits:
const emit = defineEmits<{
  move: [jobId: number, preSelectedStage?: PipelineStage]
  prep: [jobId: number]
}>()

// Signal state
const sigExpanded = ref(false)

interface SignalMeta {
  label: string
  stage: PipelineStage
  color: 'amber' | 'green' | 'red'
}

const SIGNAL_META: Record<StageSignal['stage_signal'], SignalMeta> = {
  interview_scheduled: { label: 'Move to Phone Screen', stage: 'phone_screen',       color: 'amber' },
  positive_response:   { label: 'Move to Phone Screen', stage: 'phone_screen',       color: 'amber' },
  offer_received:      { label: 'Move to Offer',        stage: 'offer',              color: 'green' },
  survey_received:     { label: 'Move to Survey',       stage: 'survey',             color: 'amber' },
  rejected:            { label: 'Mark Rejected',        stage: 'interview_rejected', color: 'red'   },
}

const COLOR_BG: Record<'amber' | 'green' | 'red', string> = {
  amber: 'rgba(245,158,11,0.08)',
  green: 'rgba(39,174,96,0.08)',
  red:   'rgba(192,57,43,0.08)',
}
const COLOR_BORDER: Record<'amber' | 'green' | 'red', string> = {
  amber: 'rgba(245,158,11,0.4)',
  green: 'rgba(39,174,96,0.4)',
  red:   'rgba(192,57,43,0.4)',
}

function visibleSignals(): StageSignal[] {
  const sigs = props.job.stage_signals ?? []
  return sigExpanded.value ? sigs : sigs.slice(0, 1)
}

async function dismissSignal(sig: StageSignal) {
  // Optimistic removal
  const arr = props.job.stage_signals
  const idx = arr.findIndex(s => s.id === sig.id)
  if (idx !== -1) arr.splice(idx, 1)
  await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
}
  • Step 2: Add signal banner template block

Add the following inside the card template, after the existing card content div but still inside the .interview-card wrapper. The banner is gated with v-if="job.stage_signals?.length".

<!-- Signal banners -->
<template v-if="job.stage_signals?.length">
  <div
    v-for="sig in visibleSignals()"
    :key="sig.id"
    class="signal-banner"
    :style="{
      background: COLOR_BG[SIGNAL_META[sig.stage_signal].color],
      borderTopColor: COLOR_BORDER[SIGNAL_META[sig.stage_signal].color],
    }"
  >
    <span class="signal-label">
      📧 Email suggests: <strong>{{ SIGNAL_META[sig.stage_signal].label }}</strong>
    </span>
    <span class="signal-subject">{{ sig.subject.slice(0, 60) }}{{ sig.subject.length > 60 ? '…' : '' }}</span>
    <div class="signal-actions">
      <button
        class="btn-signal-move"
        @click.stop="emit('move', props.job.id, SIGNAL_META[sig.stage_signal].stage)"
        :aria-label="`${SIGNAL_META[sig.stage_signal].label} for ${props.job.title}`"
      >→ {{ SIGNAL_META[sig.stage_signal].label }}</button>
      <button
        class="btn-signal-dismiss"
        @click.stop="dismissSignal(sig)"
        aria-label="Dismiss signal"
      ></button>
    </div>
  </div>
  <button
    v-if="(job.stage_signals?.length ?? 0) > 1"
    class="btn-sig-expand"
    @click.stop="sigExpanded = !sigExpanded"
  >{{ sigExpanded ? ' less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
</template>
  • Step 3: Add signal banner CSS

In the <style scoped> block, remove overflow: hidden from .interview-card and add:

/* Remove: overflow: hidden from .interview-card */

.signal-banner {
  border-top: 1px solid transparent; /* color set inline */
  padding: 8px 12px;
  display: flex; flex-direction: column; gap: 4px;
}
.signal-label  { font-size: 0.82em; }
.signal-subject { font-size: 0.78em; color: var(--color-text-muted); }
.signal-actions { display: flex; gap: 6px; align-items: center; }
.btn-signal-move {
  background: var(--color-primary); color: #fff;
  border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer;
}
.btn-signal-dismiss {
  background: none; border: none; color: var(--color-text-muted); font-size: 0.85em; cursor: pointer;
  padding: 2px 4px;
}
.btn-sig-expand {
  background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer;
  padding: 4px 12px; text-align: left;
}
  • Step 4: Type-check
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npx vue-tsc --noEmit

Expected: 0 errors.

  • Step 5: Commit
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add web/src/components/InterviewCard.vue
git commit -m "feat(interviews): add stage signal banners and extend move emit in InterviewCard"

Task 5: InterviewsView — collapsible Applied section + email sync pill

Files:

  • Modify: web/src/views/InterviewsView.vue

Context

This task wires everything visible together. Three sub-features in one file:

Feature A — Collapsible Applied section:

  • appliedExpanded ref from localStorage (key peregrine.interviews.appliedExpanded), default false
  • watch to persist changes
  • appliedSignalCount computed across store.applied + store.survey
  • .pre-list section becomes a toggle: header row is always visible; body has max-height: 0 / 800px CSS transition
  • prefers-reduced-motion disables transition

Feature B — Email sync pill:

  • Local SyncStatus ref (not in Pinia store — view-only)
  • On mount: call GET /api/email/sync/status to hydrate; start polling if already running
  • POST /api/email/sync → if 503, set not_configured permanently for session; else poll every 3s
  • Poll stop: status completed or failed; onUnmounted clears interval; on completed, call store.fetchAll()
  • Elapsed-time label: setInterval ticks now ref every 60s; cleared on unmount

Feature C — preSelectedStage wiring:

  • openMove(jobId: number, preSelectedStage?: PipelineStage) extended with second param

  • movePreSelected ref stores the stage when opening

  • MoveToSheet receives :preSelectedStage="movePreSelected"; cleared on close

  • Step 1: Add script-block additions

In the <script setup> block, after the existing imports and before const moveTarget:

import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
// (ref, onMounted, onUnmounted already imported — just ensure computed + watch are included)

// ── Collapsible Applied section ────────────────────────────────────────────
const APPLIED_EXPANDED_KEY = 'peregrine.interviews.appliedExpanded'
const appliedExpanded = ref(localStorage.getItem(APPLIED_EXPANDED_KEY) === 'true')
watch(appliedExpanded, v => localStorage.setItem(APPLIED_EXPANDED_KEY, String(v)))

const appliedSignalCount = computed(() =>
  [...store.applied, ...store.survey]
    .reduce((n, job) => n + (job.stage_signals?.length ?? 0), 0)
)

// ── Email sync status ──────────────────────────────────────────────────────
interface SyncStatus {
  state: 'idle' | 'queued' | 'running' | 'completed' | 'failed' | 'not_configured'
  lastCompletedAt: string | null
  error: string | null
}

const syncStatus = ref<SyncStatus>({ state: 'idle', lastCompletedAt: null, error: null })
const now        = ref(Date.now())
let   syncPollId: ReturnType<typeof setInterval> | null = null
let   nowTickId:  ReturnType<typeof setInterval> | null = null

function elapsedLabel(isoTs: string | null): string {
  if (!isoTs) return ''
  const diffMs = now.value - new Date(isoTs).getTime()
  const mins   = Math.floor(diffMs / 60000)
  if (mins < 1)   return 'just now'
  if (mins < 60)  return `${mins}m ago`
  const hrs = Math.floor(mins / 60)
  if (hrs < 24)   return `${hrs}h ago`
  return `${Math.floor(hrs / 24)}d ago`
}

async function fetchSyncStatus() {
  const { data } = await useApiFetch<{
    status: string; last_completed_at: string | null; error: string | null
  }>('/api/email/sync/status')
  if (!data) return
  syncStatus.value = {
    state:           data.status as SyncStatus['state'],
    lastCompletedAt: data.last_completed_at,
    error:           data.error,
  }
}

function startSyncPoll() {
  if (syncPollId) return
  syncPollId = setInterval(async () => {
    await fetchSyncStatus()
    if (syncStatus.value.state === 'completed' || syncStatus.value.state === 'failed') {
      clearInterval(syncPollId!); syncPollId = null
      if (syncStatus.value.state === 'completed') store.fetchAll()
    }
  }, 3000)
}

async function triggerSync() {
  if (syncStatus.value.state === 'queued' || syncStatus.value.state === 'running') return
  const { data, error } = await useApiFetch<{ task_id: number }>('/api/email/sync', { method: 'POST' })
  if (error) {
    if (error.kind === 'http' && error.status === 503) {
      // Email integration not configured — set permanently for this session
      syncStatus.value = { state: 'not_configured', lastCompletedAt: null, error: null }
    } else {
      // Transient error (network, server 5xx etc.) — show failed but allow retry
      syncStatus.value = { ...syncStatus.value, state: 'failed', error: error.kind === 'http' ? error.detail : error.message }
    }
    return
  }
  if (data) {
    syncStatus.value = { ...syncStatus.value, state: 'queued' }
    startSyncPoll()
  }
}
  • Step 2: Update openMove and add movePreSelected ref
// REPLACE existing:
const moveTarget = ref<PipelineJob | null>(null)
function openMove(jobId: number) {
  moveTarget.value = store.jobs.find(j => j.id === jobId) ?? null
}

// WITH:
const moveTarget      = ref<PipelineJob | null>(null)
const movePreSelected = ref<PipelineStage | undefined>(undefined)

function openMove(jobId: number, preSelectedStage?: PipelineStage) {
  moveTarget.value      = store.jobs.find(j => j.id === jobId) ?? null
  movePreSelected.value = preSelectedStage
}
  • Step 3: Update onMounted and onUnmounted
// REPLACE:
onMounted(async () => { await store.fetchAll(); document.addEventListener('keydown', onKeydown) })
onUnmounted(() => document.removeEventListener('keydown', onKeydown))

// WITH:
onMounted(async () => {
  await store.fetchAll()
  document.addEventListener('keydown', onKeydown)
  await fetchSyncStatus()
  if (syncStatus.value.state === 'queued' || syncStatus.value.state === 'running') {
    startSyncPoll()
  }
  nowTickId = setInterval(() => { now.value = Date.now() }, 60000)
})

onUnmounted(() => {
  document.removeEventListener('keydown', onKeydown)
  if (syncPollId) { clearInterval(syncPollId); syncPollId = null }
  if (nowTickId)  { clearInterval(nowTickId);  nowTickId  = null }
})
  • Step 4: Update the template — page header (email sync pill)

Replace the existing <header class="view-header"> block:

<header class="view-header">
  <h1 class="view-title">Interviews</h1>
  <div class="header-actions">
    <!-- Email sync pill -->
    <button
      v-if="syncStatus.state === 'not_configured'"
      class="sync-pill sync-pill--muted"
      disabled
      aria-label="Email not configured"
    >📧 Email not configured</button>
    <button
      v-else-if="syncStatus.state === 'queued' || syncStatus.state === 'running'"
      class="sync-pill sync-pill--syncing"
      disabled
      aria-label="Syncing emails"
    >⏳ Syncing…</button>
    <button
      v-else-if="syncStatus.state === 'completed' || (syncStatus.state === 'idle' && syncStatus.lastCompletedAt)"
      class="sync-pill sync-pill--synced"
      @click="triggerSync"
      :aria-label="`Email synced ${elapsedLabel(syncStatus.lastCompletedAt)} — click to re-sync`"
    >📧 Synced {{ elapsedLabel(syncStatus.lastCompletedAt) }}</button>
    <button
      v-else-if="syncStatus.state === 'failed'"
      class="sync-pill sync-pill--failed"
      @click="triggerSync"
      aria-label="Sync failed — click to retry"
    >⚠ Sync failed</button>
    <button
      v-else
      class="sync-pill sync-pill--idle"
      @click="triggerSync"
      aria-label="Sync emails"
    >📧 Sync Emails</button>

    <button class="btn-refresh" @click="store.fetchAll()" :disabled="store.loading" aria-label="Refresh">
      {{ store.loading ? '⟳' : '↺' }}
    </button>
  </div>
</header>
  • Step 5: Update the template — collapsible pre-list section

Replace the existing <section class="pre-list" ...> block (lines 132152):

<!-- Pre-list: Applied + Survey (collapsible) -->
<section class="pre-list" aria-label="Applied jobs">
  <button
    class="pre-list-toggle"
    @click="appliedExpanded = !appliedExpanded"
    :aria-expanded="appliedExpanded"
    aria-controls="pre-list-body"
  >
    <span class="pre-list-chevron" :class="{ 'is-expanded': appliedExpanded }"></span>
    <span class="pre-list-toggle-title">
      Applied
      <span class="pre-list-count">{{ store.applied.length + store.survey.length }}</span>
    </span>
    <span v-if="appliedSignalCount > 0" class="pre-list-signal-count">⚡ {{ appliedSignalCount }} signal{{ appliedSignalCount !== 1 ? 's' : '' }}</span>
  </button>

  <div
    id="pre-list-body"
    class="pre-list-body"
    :class="{ 'is-expanded': appliedExpanded }"
  >
    <div v-if="store.applied.length === 0 && store.survey.length === 0" class="pre-list-empty">
      <span class="empty-bird">🦅</span>
      <span>No applied jobs yet. <RouterLink to="/apply">Go to Apply</RouterLink> to submit applications.</span>
    </div>
    <template v-for="job in [...store.applied, ...store.survey]" :key="job.id">
      <div class="pre-list-row">
        <div class="pre-row-info">
          <span class="pre-row-title">{{ job.title }}</span>
          <span class="pre-row-company">{{ job.company }}</span>
          <span v-if="job.status === 'survey'" class="survey-badge">Survey</span>
        </div>
        <div class="pre-row-meta">
          <span v-if="daysSince(job.applied_at) !== null" class="pre-row-days">{{ daysSince(job.applied_at) }}d ago</span>
          <button class="btn-move-pre" @click="openMove(job.id)" :aria-label="`Move ${job.title}`">Move to… </button>
        </div>
      </div>
      <!-- Signal banners for pre-list rows -->
      <template v-if="job.stage_signals?.length">
        <div
          v-for="sig in (job.stage_signals ?? []).slice(0, sigExpandedIds.has(job.id) ? undefined : 1)"
      <!-- Note: inside <template>, Vue auto-unwraps refs — sigExpandedIds.has() is correct here (no .value needed) -->
          :key="sig.id"
          class="pre-signal-banner"
          :data-color="SIGNAL_META_PRE[sig.stage_signal]?.color"
        >
          <span class="signal-label">📧 Email suggests: <strong>{{ SIGNAL_META_PRE[sig.stage_signal]?.label }}</strong></span>
          <span class="signal-subject">{{ sig.subject.slice(0, 60) }}{{ sig.subject.length > 60 ? '…' : '' }}</span>
          <div class="signal-actions">
            <button
              class="btn-signal-move"
              @click="openMove(job.id, SIGNAL_META_PRE[sig.stage_signal]?.stage)"
            >→ {{ SIGNAL_META_PRE[sig.stage_signal]?.label }}</button>
            <button class="btn-signal-dismiss" @click="dismissPreSignal(job, sig)"></button>
          </div>
        </div>
        <button
          v-if="(job.stage_signals?.length ?? 0) > 1"
          class="btn-sig-expand"
          @click="togglePreSigExpand(job.id)"
        >{{ sigExpandedIds.has(job.id) ? ' less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}
        <!-- sigExpandedIds.has() is correct in <template> — Vue auto-unwraps the ref -->
        </button>
      </template>
    </template>
  </div>
</section>

Add the required script helpers for pre-list signals. Add in <script setup>:

import type { StageSignal } from '../stores/interviews'
import { useApiFetch } from '../composables/useApi'

// Signal metadata (same map as InterviewCard — defined here for pre-list rows)
const SIGNAL_META_PRE = {
  interview_scheduled: { label: 'Move to Phone Screen', stage: 'phone_screen'       as PipelineStage, color: 'amber' },
  positive_response:   { label: 'Move to Phone Screen', stage: 'phone_screen'       as PipelineStage, color: 'amber' },
  offer_received:      { label: 'Move to Offer',        stage: 'offer'              as PipelineStage, color: 'green' },
  survey_received:     { label: 'Move to Survey',       stage: 'survey'             as PipelineStage, color: 'amber' },
  rejected:            { label: 'Mark Rejected',        stage: 'interview_rejected' as PipelineStage, color: 'red'   },
} as const

const sigExpandedIds = ref(new Set<number>())
// IMPORTANT: must reassign .value (not mutate in place) to trigger Vue reactivity
function togglePreSigExpand(jobId: number) {
  const next = new Set(sigExpandedIds.value)
  if (next.has(jobId)) next.delete(jobId)
  else next.add(jobId)
  sigExpandedIds.value = next
}

async function dismissPreSignal(job: PipelineJob, sig: StageSignal) {
  const idx = job.stage_signals.findIndex(s => s.id === sig.id)
  if (idx !== -1) job.stage_signals.splice(idx, 1)
  await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
}
  • Step 6: Update MoveToSheet binding in template
<!-- REPLACE: -->
<MoveToSheet
  v-if="moveTarget"
  :currentStatus="moveTarget.status"
  :jobTitle="`${moveTarget.title} at ${moveTarget.company}`"
  @move="onMove"
  @close="moveTarget = null"
/>

<!-- WITH: -->
<MoveToSheet
  v-if="moveTarget"
  :currentStatus="moveTarget.status"
  :jobTitle="`${moveTarget.title} at ${moveTarget.company}`"
  :preSelectedStage="movePreSelected"
  @move="onMove"
  @close="moveTarget = null; movePreSelected = undefined"
/>
  • Step 7: Add CSS for new elements

Add to the <style scoped> block:

/* Header actions */
.header-actions { display: flex; align-items: center; gap: var(--space-2); margin-left: auto; }

/* Email sync pill */
.sync-pill {
  border-radius: 999px; padding: 3px 10px; font-size: 0.78em; font-weight: 600; cursor: pointer;
  border: 1px solid transparent; transition: opacity 150ms;
}
.sync-pill:disabled { cursor: default; opacity: 0.8; }
.sync-pill--idle   { border-color: var(--color-border); background: none; color: var(--color-text-muted); }
.sync-pill--syncing { background: color-mix(in srgb, var(--color-info) 10%, var(--color-surface)); color: var(--color-info); border-color: color-mix(in srgb, var(--color-info) 30%, transparent); animation: pulse 1.5s ease-in-out infinite; }
.sync-pill--synced  { background: color-mix(in srgb, var(--color-success) 12%, var(--color-surface)); color: var(--color-success); border-color: color-mix(in srgb, var(--color-success) 30%, transparent); }
.sync-pill--failed  { background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.sync-pill--muted   { background: var(--color-surface-alt); color: var(--color-text-muted); border-color: var(--color-border-light); }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.55} }

/* Collapsible pre-list toggle header */
.pre-list-toggle {
  display: flex; align-items: center; gap: var(--space-2); width: 100%;
  background: none; border: none; cursor: pointer; padding: var(--space-1) 0;
  font-size: 0.9rem; font-weight: 700; color: var(--color-text);
  text-align: left;
}
.pre-list-chevron { font-size: 0.7em; color: var(--color-text-muted); transition: transform 200ms; display: inline-block; }
.pre-list-chevron.is-expanded { transform: rotate(90deg); }
.pre-list-count {
  display: inline-block; background: var(--color-surface-raised); border-radius: 999px;
  padding: 1px 8px; font-size: 0.75em; font-weight: 700; margin-left: var(--space-1);
  color: var(--color-text-muted);
}
.pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: #e67e22; }

/* Collapsible pre-list body */
.pre-list-body {
  max-height: 0;
  overflow: hidden;
  transition: max-height 300ms ease;
}
.pre-list-body.is-expanded { max-height: 800px; }
@media (prefers-reduced-motion: reduce) {
  .pre-list-body, .pre-list-chevron { transition: none; }
}

/* Pre-list signal banners */
.pre-signal-banner {
  padding: 8px 12px; border-radius: 6px; margin: 4px 0;
  border-top: 1px solid transparent;
  display: flex; flex-direction: column; gap: 4px;
}
.pre-signal-banner[data-color="amber"] { background: rgba(245,158,11,0.08); border-top-color: rgba(245,158,11,0.4); }
.pre-signal-banner[data-color="green"] { background: rgba(39,174,96,0.08);  border-top-color: rgba(39,174,96,0.4);  }
.pre-signal-banner[data-color="red"]   { background: rgba(192,57,43,0.08);  border-top-color: rgba(192,57,43,0.4);  }
  • Step 8: Type-check
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npx vue-tsc --noEmit

Expected: 0 errors.

  • Step 9: Commit
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add web/src/views/InterviewsView.vue
git commit -m "feat(interviews): collapsible Applied section, email sync pill, pre-list signal banners"

Task 6: Final verification

Files: none changed

  • Step 1: Run Python test suite
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v --ignore=tests/e2e

Expected: all tests pass.

  • Step 2: Full TypeScript type-check
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npx vue-tsc --noEmit

Expected: 0 errors.

  • Step 3: Build check
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npm run build

Expected: build succeeds with 0 errors.

  • Step 4: Manual smoke-test checklist (run dev server: npm run dev)

  • Interviews page loads; Applied section is collapsed by default

  • Clicking the header row expands/collapses with animation

  • Collapse state persists on page reload (localStorage key peregrine.interviews.appliedExpanded)

  • ⚡ N signals count shown in collapsed header when signals exist

  • Email sync pill shows "📧 Sync Emails" on first load

  • Clicking the pill triggers a POST; pill shows " Syncing…" while polling

  • Stage signal banners appear at the bottom of InterviewCard in kanban columns

  • [→ Move] button on a signal banner opens MoveToSheet with the correct stage pre-selected

  • [✕] on a banner optimistically removes it (no page reload needed)

  • +N more / less toggle works for jobs with multiple signals