docs(interviews): fix spec review issues (SQL column, emit signature, stage mapping, event_rescheduled)

This commit is contained in:
pyr0ball 2026-03-19 09:32:26 -07:00
parent 12f714abb8
commit cbdbed9a8e

View file

@ -10,7 +10,7 @@
Add three improvements to the Vue SPA Interviews page: Add three improvements to the Vue SPA Interviews page:
1. Collapse the Applied/Survey pre-kanban strip so the kanban board is immediately visible 1. Collapse the Applied/Survey pre-kanban strip so the kanban board is immediately visible
2. Email sync status pill in the page header 2. Email sync status pill in the page header
3. Stage signal banners on job cards (email-detected flags with actionable move/dismiss buttons) 3. Stage signal banners on job cards — in both the Applied/Survey pre-list rows and the kanban `InterviewCard` components
--- ---
@ -22,10 +22,13 @@ Add three improvements to the Vue SPA Interviews page:
| Collapse persistence | `localStorage` key `peregrine.interviews.appliedExpanded` | | Collapse persistence | `localStorage` key `peregrine.interviews.appliedExpanded` |
| Signal visibility when collapsed | `⚡ N signals` count shown in collapsed header | | Signal visibility when collapsed | `⚡ N signals` count shown in collapsed header |
| Email sync placement | Page header status pill (right side, beside ↻ Refresh) | | Email sync placement | Page header status pill (right side, beside ↻ Refresh) |
| Signal banner placement | Inline at bottom of InterviewCard | | Signal banner placement | Applied/Survey pre-list rows AND InterviewCard kanban cards |
| Signal data loading | Batched with `GET /api/interviews` response (no N+1 requests) | | Signal data loading | Batched with `GET /api/interviews` response (no N+1 requests) |
| Multiple signals | Show most recent; `+N more` link expands the rest | | Multiple signals | Show most recent; `+N more` expands rest inline; click again to collapse |
| Signal dismiss | Optimistic removal; `POST /api/stage-signals/{id}/dismiss` | | Signal dismiss | Optimistic removal; `POST /api/stage-signals/{id}/dismiss` |
| MoveToSheet pre-selection | New optional `preSelectedStage?: PipelineStage` prop on MoveToSheet |
| Email not configured | `POST /api/email/sync` returns 503; pill shows muted `📧 Email not configured` (non-interactive) |
| Polling teardown | Stop polling on `onUnmounted`; hydrate status from `GET /api/email/sync/status` on mount |
--- ---
@ -33,9 +36,9 @@ Add three improvements to the Vue SPA Interviews page:
### Behavior ### Behavior
The pre-kanban "Applied + Survey" strip (currently rendered above the three kanban columns) becomes a toggle section. Survey jobs remain in the same section as now. The pre-kanban "Applied + Survey" strip (currently rendered above the three kanban columns) becomes a toggle section. Survey jobs remain in the same section.
**Default state:** collapsed on page load. `localStorage` is checked first — if the user has previously expanded it, it opens expanded. **Default state:** collapsed on page load, unless `localStorage` indicates the user previously expanded it.
**Header row (always visible):** **Header row (always visible):**
@ -46,20 +49,22 @@ The pre-kanban "Applied + Survey" strip (currently rendered above the three kanb
- Arrow chevron toggles on click (anywhere on the header row) - Arrow chevron toggles on click (anywhere on the header row)
- Count badge: total applied + survey jobs - Count badge: total applied + survey jobs
- Signal indicator: `⚡ N signals` in amber — only shown when there are undismissed stage signals among the applied/survey jobs. Hidden when zero. - Signal indicator: `⚡ N signals` in amber — shown only when there are undismissed signals across applied/survey jobs. Hidden when N = 0.
- Smooth CSS `max-height` transition (200ms ease-out). `prefers-reduced-motion`: instant toggle. - CSS `max-height` transition: transition from `0` to `800px` (safe cap — enough for any real list). `prefers-reduced-motion`: instant toggle (no transition).
**Expanded state:** renders the existing applied/survey job rows, unchanged from current behavior. **Expanded state:** renders the existing applied/survey job rows with signal banners (see Feature 3).
### localStorage ### localStorage
```typescript ```typescript
const APPLIED_EXPANDED_KEY = 'peregrine.interviews.appliedExpanded' const APPLIED_EXPANDED_KEY = 'peregrine.interviews.appliedExpanded'
// default: false (collapsed) // default: false (collapsed). localStorage returns null on first load → defaults to false.
const appliedExpanded = ref(localStorage.getItem(APPLIED_EXPANDED_KEY) === 'true') const appliedExpanded = ref(localStorage.getItem(APPLIED_EXPANDED_KEY) === 'true')
watch(appliedExpanded, v => localStorage.setItem(APPLIED_EXPANDED_KEY, String(v))) watch(appliedExpanded, v => localStorage.setItem(APPLIED_EXPANDED_KEY, String(v)))
``` ```
`localStorage.getItem(...)` returns `null` on first load; `null === 'true'` is `false`, so the section starts collapsed correctly.
--- ---
## Feature 2: Email Sync Status Pill ## Feature 2: Email Sync Status Pill
@ -70,115 +75,157 @@ Right side of the Interviews page header, alongside the existing ↻ Refresh but
### States ### States
| State | Appearance | Interaction | | API `status` + `last_completed_at` | Pill appearance | Interaction |
|---|---|---| |---|---|---|
| Never synced | `📧 Sync Emails` (outlined button) | Click → trigger sync | | No API call yet / `idle` + `null` | `📧 Sync Emails` (outlined button) | Click → trigger sync |
| Queued / Running | `⏳ Syncing…` (disabled, pulse animation) | Non-interactive | | `idle` + timestamp exists | `📧 Synced 4m ago` (green pill) | Click → re-trigger sync |
| Completed | `📧 Synced 4m ago` (green pill) | Click → re-trigger sync | | `queued` or `running` | `⏳ Syncing…` (disabled, pulse animation) | Non-interactive |
| Failed | `⚠ Sync failed` (amber pill) | Click → retry | | `completed` | `📧 Synced 4m ago` (green pill) | Click → re-trigger sync |
| `failed` | `⚠ Sync failed` (amber pill) | Click → retry |
| 503 from `POST /api/email/sync` | `📧 Email not configured` (muted, non-interactive) | None |
The elapsed-time label ("4m ago") is computed from `lastSyncedAt` timestamp. Updates every 60 seconds via a `setInterval` in `onMounted`, cleared in `onUnmounted`. The elapsed-time label ("4m ago") is computed from `lastSyncedAt` using a reactive tick. A `setInterval` updates a `now` ref every 60 seconds in `onMounted`, cleared in `onUnmounted`.
### Lifecycle
**On mount:** call `GET /api/email/sync/status` once to hydrate pill state. If status is `queued` or `running` (sync was in progress before navigation), start polling immediately.
**On sync trigger:** `POST /api/email/sync` → if 503, set pill to "Email not configured" permanently for the session. Otherwise poll `GET /api/email/sync/status` every 3 seconds.
**Polling stop conditions:** status becomes `completed` or `failed`, OR component unmounts (`onUnmounted` clears the interval). On `completed`, re-fetch the interview job list to pick up new signals.
### API ### API
**Trigger sync:** **Trigger sync:**
``` ```
POST /api/email/sync POST /api/email/sync
→ 202 { task_id: number } → 202 { task_id: number } (sync queued)
→ 503 { detail: "Email not configured" } (no email integration)
``` ```
Inserts a `background_tasks` row with `task_type = "email_sync"`, `job_id = 0`. Returns immediately. Inserts a `background_tasks` row with `task_type = "email_sync"`, `job_id = 0` (sentinel for global/non-job tasks).
**Poll status:** **Poll status:**
``` ```
GET /api/email/sync/status GET /api/email/sync/status
→ { status: "idle" | "queued" | "running" | "completed" | "failed", → {
last_completed_at: string | null, status: "idle" | "queued" | "running" | "completed" | "failed",
error: string | null } last_completed_at: string | null, // ISO timestamp or null
error: string | null
}
``` ```
Polls every 3 seconds while status is `queued` or `running`. Stops polling on `completed` or `failed`. On `completed`, re-fetches the interview job list (to pick up new signals). Implementation: `SELECT status, finished_at AS last_completed_at FROM background_tasks WHERE task_type = 'email_sync' ORDER BY id DESC LIMIT 1`. If no rows: return `{ status: "idle", last_completed_at: null, error: null }`. Note: the column is `finished_at` (not `completed_at`) per the `background_tasks` schema.
### Store action ### Store shape
```typescript
// stores/interviews.ts
async function syncEmails() { ... } // sets syncStatus ref, polls, re-fetches on complete
```
`syncStatus` ref shape:
```typescript ```typescript
interface SyncStatus { interface SyncStatus {
state: 'idle' | 'queued' | 'running' | 'completed' | 'failed' state: 'idle' | 'queued' | 'running' | 'completed' | 'failed' | 'not_configured'
lastCompletedAt: string | null lastCompletedAt: string | null
error: string | null error: string | null
} }
// ref in stores/interviews.ts, or local ref in InterviewsView.vue
``` ```
The sync state can live as a local ref in `InterviewsView.vue` (not in the Pinia store) since it's view-only state with no cross-component consumers.
--- ---
## Feature 3: Stage Signal Banners ## Feature 3: Stage Signal Banners
### Data Model ### Data Model
`GET /api/interviews` response now includes `stage_signals` per job: `GET /api/interviews` response includes `stage_signals` per job. Implementation: after the main jobs query, run a second query:
```sql
SELECT id, job_id, subject, received_at, stage_signal
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
```
Group results by `job_id` in Python and attach to each job dict. Empty list `[]` if no signals.
The `StageSignal.id` is `job_contacts.id` — the contact row id, used for the dismiss endpoint.
```typescript ```typescript
interface StageSignal { // Export from stores/interviews.ts so InterviewCard.vue can import it
id: number export interface StageSignal {
id: number // job_contacts.id — used for POST /api/stage-signals/{id}/dismiss
subject: string subject: string
received_at: string received_at: string // ISO timestamp
stage_signal: 'interview_scheduled' | 'positive_response' | 'offer_received' | 'survey_received' | 'rejected' stage_signal: 'interview_scheduled' | 'positive_response' | 'offer_received' | 'survey_received' | 'rejected'
// 'event_rescheduled' is excluded server-side; other classifier labels filtered at query level
} }
interface PipelineJob { export interface PipelineJob {
// ... existing fields // ... existing fields
stage_signals: StageSignal[] // undismissed signals only, newest first stage_signals: StageSignal[] // undismissed signals, newest first
} }
``` ```
Signals are filtered server-side: `suggestion_dismissed = 0` and `stage_signal NOT IN ('neutral', 'unrelated', 'digest', null)`. ### Signal Label + Color Map
### Signal Label Map | Signal type | Suggested action label | Banner accent | `preSelectedStage` value |
|---|---|---|---|
| `interview_scheduled` | Move to Phone Screen | Amber | `'phone_screen'` |
| `positive_response` | Move to Phone Screen | Amber | `'phone_screen'` |
| `offer_received` | Move to Offer | Green | `'offer'` |
| `survey_received` | Move to Survey | Amber | `'survey'` |
| `rejected` | Mark Rejected | Red | `'interview_rejected'` |
| Signal type | Label | Banner color | Note: `'rejected'` maps to the stage value `'interview_rejected'` (not `'rejected'`) — this non-obvious mapping must be hardcoded in the signal banner logic.
|---|---|---|
| `interview_scheduled` | Move to Phone Screen | Amber | ### Where Banners Appear
| `positive_response` | Move to Phone Screen | Amber |
| `offer_received` | Move to Offer | Green | Signal banners appear in **both** locations:
| `survey_received` | Move to Survey | Amber |
| `rejected` | Mark Rejected | Red | 1. **Applied/Survey pre-list rows** (in `InterviewsView.vue`) — inline below the existing row content
2. **Kanban `InterviewCard` components** (phone_screen / interviewing / offer columns) — at the bottom of the card, inside the card border
This ensures the `⚡ N signals` count in the Applied section header points to visible, actionable banners in that section.
### Banner Layout ### Banner Layout
Rendered at the bottom of `InterviewCard`, inside the card border:
``` ```
┌──────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────┐
│ [card content] │ [existing card / row content]
│────────────────────────────────────────────── │──────────────────────────────────────────────────│ ← colored top border (40% opacity)
│ 📧 Email suggests: Move to Phone Screen │ │ 📧 Email suggests: Move to Phone Screen
│ "Interview confirmed for Tuesday…" [→ Move] [✕] │ │ "Interview confirmed for Tuesday…" [→ Move] [✕] │
└──────────────────────────────────────────────┘ └──────────────────────────────────────────────────
``` ```
- Background: `rgba(245,158,11,0.08)` amber / `rgba(39,174,96,0.08)` green / `rgba(192,57,43,0.08)` red - Background tint: `rgba(245,158,11,0.08)` amber / `rgba(39,174,96,0.08)` green / `rgba(192,57,43,0.08)` red
- Top border matching color (1px, 40% opacity) - Top border: 1px solid matching accent at 40% opacity
- Subject line truncated to ~60 chars - Subject line: truncated to ~60 chars with ellipsis
- **[→ Move]** button: opens `MoveToSheet` pre-selected to the suggested stage - **[→ Move]** button: emits `move: [jobId: number, preSelectedStage: PipelineStage]` up to `InterviewsView.vue`, which passes `preSelectedStage` to `MoveToSheet` when opening it. The `InterviewCard` `move` emit signature is extended from `move: [jobId: number]` to `move: [jobId: number, preSelectedStage?: PipelineStage]` — the second argument is optional so existing non-signal `move` calls remain unchanged.
- **[✕]** button: dismisses signal (optimistic — removes from local array immediately, then `POST /api/stage-signals/{id}/dismiss`) - **[✕]** dismiss button: optimistic removal from local `stage_signals` array, then `POST /api/stage-signals/{id}/dismiss`
**Multiple signals:** only the most recent signal banner is shown by default. If `stage_signals.length > 1`, a `+N more` link at the bottom of the banner expands to show all signals stacked. Each has its own dismiss button. **Multiple signals:** when `stage_signals.length > 1`, only the most recent banner shows. A `+N more` link below it expands to show all signals stacked; clicking ` less` (same element, toggled) collapses back to one. Each expanded signal has its own `[✕]` button.
**Empty signals:** `v-if="job.stage_signals?.length"` gates the entire banner — nothing renders when the array is empty or undefined.
### Dismiss API ### Dismiss API
``` ```
POST /api/stage-signals/{id}/dismiss POST /api/stage-signals/{id}/dismiss (id = job_contacts.id)
→ 200 { ok: true } → 200 { ok: true }
``` ```
Sets `suggestion_dismissed = 1` in `job_contacts` table.
Sets `suggestion_dismissed = 1` in `job_contacts` for that row. Optimistic update: remove from local `stage_signals` array immediately on click, before API response.
### Applied section signal count ### Applied section signal count
The collapsed Applied section header shows `⚡ N signals` where N = total undismissed signals across all applied/survey jobs. Computed from `store.applied` + `store.survey` → sum of `stage_signals.length`. ```typescript
// Computed in InterviewsView.vue
const appliedSignalCount = computed(() =>
[...store.applied, ...store.survey]
.reduce((n, job) => n + (job.stage_signals?.length ?? 0), 0)
)
```
--- ---
@ -186,17 +233,19 @@ The collapsed Applied section header shows `⚡ N signals` where N = total undis
| File | Action | | File | Action |
|---|---| |---|---|
| `web/src/views/InterviewsView.vue` | Collapsible Applied section + email sync pill | | `web/src/views/InterviewsView.vue` | Collapsible Applied section (toggle, localStorage, `max-height` CSS, signal count in header); email sync pill + polling in header |
| `web/src/components/InterviewCard.vue` | Stage signal banner | | `web/src/components/InterviewCard.vue` | Stage signal banner at card bottom; import `StageSignal` from store |
| `web/src/stores/interviews.ts` | `stage_signals` on `PipelineJob`; `syncEmails()` action; `SyncStatus` ref | | `web/src/components/MoveToSheet.vue` | Add optional `preSelectedStage?: PipelineStage` prop; pre-select stage button on open |
| `dev-api.py` | `stage_signals` in `/api/interviews`; `POST /api/email/sync`; `GET /api/email/sync/status`; `POST /api/stage-signals/{id}/dismiss` | | `web/src/components/InterviewCard.vue` (emit) | Extend `move` emit: `move: [jobId: number, preSelectedStage?: PipelineStage]` — second arg passed from signal banner `[→ Move]` button; existing card move button continues passing `undefined` |
| `web/src/stores/interviews.ts` | Export `StageSignal` interface; add `stage_signals: StageSignal[]` to `PipelineJob`; update `_row_to_job()` equivalent |
| `dev-api.py` | `stage_signals` nested in `/api/interviews` (second query + Python grouping); `POST /api/email/sync`; `GET /api/email/sync/status`; `POST /api/stage-signals/{id}/dismiss` |
--- ---
## What Stays the Same ## What Stays the Same
- Kanban columns (Phone Screen → Interviewing → Offer/Hired) — unchanged - Kanban columns (Phone Screen → Interviewing → Offer/Hired) — layout unchanged
- MoveToSheet modal — unchanged (reused by signal "→ Move" action) - MoveToSheet modal — existing behavior unchanged; only a new optional prop added
- Rejected section — unchanged - Rejected section — unchanged
- InterviewCard content above the signal banner — unchanged - InterviewCard content above the signal banner — unchanged
- Keyboard navigation — unchanged - Keyboard navigation — unchanged