From cbdbed9a8e10ccd9f44ae21aff88a48e469cc550 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Thu, 19 Mar 2026 09:32:26 -0700 Subject: [PATCH] docs(interviews): fix spec review issues (SQL column, emit signature, stage mapping, event_rescheduled) --- ...26-03-19-interviews-improvements-design.md | 183 +++++++++++------- 1 file changed, 116 insertions(+), 67 deletions(-) diff --git a/docs/superpowers/specs/2026-03-19-interviews-improvements-design.md b/docs/superpowers/specs/2026-03-19-interviews-improvements-design.md index 9332a48..412ea39 100644 --- a/docs/superpowers/specs/2026-03-19-interviews-improvements-design.md +++ b/docs/superpowers/specs/2026-03-19-interviews-improvements-design.md @@ -10,7 +10,7 @@ Add three improvements to the Vue SPA Interviews page: 1. Collapse the Applied/Survey pre-kanban strip so the kanban board is immediately visible 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` | | Signal visibility when collapsed | `⚑ N signals` count shown in collapsed header | | 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) | -| 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` | +| 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 -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):** @@ -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) - 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. -- Smooth CSS `max-height` transition (200ms ease-out). `prefers-reduced-motion`: instant toggle. +- Signal indicator: `⚑ N signals` in amber β€” shown only when there are undismissed signals across applied/survey jobs. Hidden when N = 0. +- 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 ```typescript 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') 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 @@ -70,115 +75,157 @@ Right side of the Interviews page header, alongside the existing ↻ Refresh but ### States -| State | Appearance | Interaction | +| API `status` + `last_completed_at` | Pill appearance | Interaction | |---|---|---| -| Never synced | `πŸ“§ Sync Emails` (outlined button) | Click β†’ trigger sync | -| Queued / Running | `⏳ Syncing…` (disabled, pulse animation) | Non-interactive | -| Completed | `πŸ“§ Synced 4m ago` (green pill) | Click β†’ re-trigger sync | -| Failed | `⚠ Sync failed` (amber pill) | Click β†’ retry | +| No API call yet / `idle` + `null` | `πŸ“§ Sync Emails` (outlined button) | Click β†’ trigger sync | +| `idle` + timestamp exists | `πŸ“§ Synced 4m ago` (green pill) | Click β†’ re-trigger sync | +| `queued` or `running` | `⏳ Syncing…` (disabled, pulse animation) | Non-interactive | +| `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 **Trigger 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:** ``` GET /api/email/sync/status -β†’ { status: "idle" | "queued" | "running" | "completed" | "failed", - last_completed_at: string | null, - error: string | null } +β†’ { + status: "idle" | "queued" | "running" | "completed" | "failed", + 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 interface SyncStatus { - state: 'idle' | 'queued' | 'running' | 'completed' | 'failed' + state: 'idle' | 'queued' | 'running' | 'completed' | 'failed' | 'not_configured' lastCompletedAt: 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 ### 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 -interface StageSignal { - id: number +// Export from stores/interviews.ts so InterviewCard.vue can import it +export interface StageSignal { + id: number // job_contacts.id β€” used for POST /api/stage-signals/{id}/dismiss subject: string - received_at: string + received_at: string // ISO timestamp 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 - 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 | -|---|---|---| -| `interview_scheduled` | Move to Phone Screen | Amber | -| `positive_response` | Move to Phone Screen | Amber | -| `offer_received` | Move to Offer | Green | -| `survey_received` | Move to Survey | Amber | -| `rejected` | Mark Rejected | Red | +Note: `'rejected'` maps to the stage value `'interview_rejected'` (not `'rejected'`) β€” this non-obvious mapping must be hardcoded in the signal banner logic. + +### Where Banners Appear + +Signal banners appear in **both** locations: + +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 -Rendered at the bottom of `InterviewCard`, inside the card border: - ``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ [card content] β”‚ -│──────────────────────────────────────────────│ -β”‚ πŸ“§ Email suggests: Move to Phone Screen β”‚ -β”‚ "Interview confirmed for Tuesday…" [β†’ Move] [βœ•] β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ [existing card / row content] β”‚ +│──────────────────────────────────────────────────│ ← colored top border (40% opacity) +β”‚ πŸ“§ Email suggests: Move to Phone Screen β”‚ +β”‚ "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 -- Top border matching color (1px, 40% opacity) -- Subject line truncated to ~60 chars -- **[β†’ Move]** button: opens `MoveToSheet` pre-selected to the suggested stage -- **[βœ•]** button: dismisses signal (optimistic β€” removes from local array immediately, then `POST /api/stage-signals/{id}/dismiss`) +- 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: 1px solid matching accent at 40% opacity +- Subject line: truncated to ~60 chars with ellipsis +- **[β†’ 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. +- **[βœ•]** 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 ``` -POST /api/stage-signals/{id}/dismiss +POST /api/stage-signals/{id}/dismiss (id = job_contacts.id) β†’ 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 -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 | |---|---| -| `web/src/views/InterviewsView.vue` | Collapsible Applied section + email sync pill | -| `web/src/components/InterviewCard.vue` | Stage signal banner | -| `web/src/stores/interviews.ts` | `stage_signals` on `PipelineJob`; `syncEmails()` action; `SyncStatus` ref | -| `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/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 at card bottom; import `StageSignal` from store | +| `web/src/components/MoveToSheet.vue` | Add optional `preSelectedStage?: PipelineStage` prop; pre-select stage button on open | +| `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 -- Kanban columns (Phone Screen β†’ Interviewing β†’ Offer/Hired) β€” unchanged -- MoveToSheet modal β€” unchanged (reused by signal "β†’ Move" action) +- Kanban columns (Phone Screen β†’ Interviewing β†’ Offer/Hired) β€” layout unchanged +- MoveToSheet modal β€” existing behavior unchanged; only a new optional prop added - Rejected section β€” unchanged - InterviewCard content above the signal banner β€” unchanged - Keyboard navigation β€” unchanged