peregrine/web/src/components/InterviewCard.vue
pyr0ball 0e4fce44c4 feat: shadow listing detector, hired feedback widget, contacts manager
Shadow listing detector (#95):
- Capture date_posted from JobSpy in discover.py + insert_job()
- Add date_posted migration to _MIGRATIONS
- _shadow_score() heuristic: 'shadow' (≥30 days stale), 'stale' (≥14 days)
- list_jobs() computes shadow_score per listing
- JobCard.vue: 'Ghost post' and 'Stale' badges with tooltip

Post-hire feedback widget (#91):
- Add hired_feedback migration to _MIGRATIONS
- POST /api/jobs/:id/hired-feedback endpoint
- InterviewCard.vue: optional widget on hired cards with factor
  checkboxes + freetext; dismissible; shows saved state
- PipelineJob interface extended with hired_feedback field

Contacts manager (#73):
- GET /api/contacts endpoint with job join, direction/search filters
- New ContactsView.vue: searchable table, inbound/outbound filter,
  signal chip column, job link
- Route /contacts added; Contacts nav link (UsersIcon) in AppNav

Also: add git to Dockerfile apt-get for circuitforge-core editable install
2026-04-15 08:34:12 -07:00

675 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed } from 'vue'
import type { PipelineJob } from '../stores/interviews'
import type { StageSignal, PipelineStage } from '../stores/interviews'
import { useApiFetch } from '../composables/useApi'
// ── Date picker ────────────────────────────────────────────────────────────────
const DATE_STAGES = new Set(['phone_screen', 'interviewing'])
function toDatetimeLocal(iso: string | null | undefined): string {
if (!iso) return ''
// Trim seconds/ms so <input type="datetime-local"> accepts it
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function onDateChange(value: string) {
if (!value) return
const prev = props.job.interview_date
// Optimistic update
props.job.interview_date = new Date(value).toISOString()
const { error } = await useApiFetch(`/api/jobs/${props.job.id}/interview_date`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interview_date: value }),
})
if (error) props.job.interview_date = prev
}
// ── Calendar push ──────────────────────────────────────────────────────────────
type CalPushStatus = 'idle' | 'loading' | 'synced' | 'failed'
const calPushStatus = ref<CalPushStatus>('idle')
let calPushTimer: ReturnType<typeof setTimeout> | null = null
async function pushCalendar() {
if (calPushStatus.value === 'loading') return
calPushStatus.value = 'loading'
const { error } = await useApiFetch(`/api/jobs/${props.job.id}/calendar_push`, { method: 'POST' })
calPushStatus.value = error ? 'failed' : 'synced'
if (calPushTimer) clearTimeout(calPushTimer)
calPushTimer = setTimeout(() => { calPushStatus.value = 'idle' }, 3000)
}
const props = defineProps<{
job: PipelineJob
focused?: boolean
}>()
const emit = defineEmits<{
move: [jobId: number, preSelectedStage?: PipelineStage]
prep: [jobId: number]
survey: [jobId: number]
research: [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' })
}
const expandedSignalIds = ref(new Set<number>())
function toggleBodyExpand(sigId: number) {
const next = new Set(expandedSignalIds.value)
if (next.has(sigId)) next.delete(sigId)
else next.add(sigId)
expandedSignalIds.value = next
}
// Re-classify chips — neutral/unrelated/digest trigger two-call dismiss path
const RECLASSIFY_CHIPS = [
{ label: '🟡 Interview', value: 'interview_scheduled' as const },
{ label: '✅ Positive', value: 'positive_response' as const },
{ label: '🟢 Offer', value: 'offer_received' as const },
{ label: '📋 Survey', value: 'survey_received' as const },
{ label: '✖ Rejected', value: 'rejected' as const },
{ label: '🚫 Unrelated', value: 'unrelated' },
{ label: '📰 Digest', value: 'digest' },
{ label: '— Neutral', value: 'neutral' },
] as const
const DISMISS_LABELS = new Set(['neutral', 'unrelated', 'digest'] as const)
async function reclassifySignal(sig: StageSignal, newLabel: StageSignal['stage_signal'] | 'neutral' | 'unrelated' | 'digest') {
if (DISMISS_LABELS.has(newLabel)) {
// Optimistic removal
const arr = props.job.stage_signals
const idx = arr.findIndex(s => s.id === sig.id)
if (idx !== -1) arr.splice(idx, 1)
// Two-call path: persist label (Avocet training hook) then dismiss
await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_signal: newLabel }),
})
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
// Digest-only: add to browsable queue (fire-and-forget; sig.id === job_contacts.id)
if (newLabel === 'digest') {
void useApiFetch('/api/digest-queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_contact_id: sig.id }),
})
}
} else {
const prev = sig.stage_signal
sig.stage_signal = newLabel
const { error } = await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_signal: newLabel }),
})
if (error) sig.stage_signal = prev
}
}
const scoreClass = computed(() => {
const s = (props.job.match_score ?? 0) * 100
if (s >= 85) return 'score--high'
if (s >= 65) return 'score--mid'
return 'score--low'
})
const scoreLabel = computed(() =>
props.job.match_score != null
? `${Math.round(props.job.match_score * 100)}%`
: '—'
)
const interviewDateLabel = computed(() => {
if (!props.job.interview_date) return null
const d = new Date(props.job.interview_date)
const now = new Date()
const diffDays = Math.round((d.getTime() - now.getTime()) / 86400000)
const timeStr = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
if (diffDays === 0) return `Today ${timeStr}`
if (diffDays === 1) return `Tomorrow ${timeStr}`
if (diffDays === -1) return `Yesterday ${timeStr}`
if (diffDays > 1 && diffDays < 7) return `${d.toLocaleDateString([], { weekday: 'short' })} ${timeStr}`
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
})
const dateChipIcon = computed(() => {
if (!props.job.interview_date) return ''
const map: Record<string, string> = { phone_screen: '📞', interviewing: '🎯', offer: '📜' }
return map[props.job.status] ?? '📅'
})
const columnColor = computed(() => {
const map: Record<string, string> = {
phone_screen: 'var(--status-phone)',
interviewing: 'var(--color-info)',
offer: 'var(--status-offer)',
hired: 'var(--color-success)',
}
return map[props.job.status] ?? 'var(--color-border)'
})
// ── Hired feedback ─────────────────────────────────────────────────────────────
const FEEDBACK_FACTORS = [
'Resume match',
'Cover letter',
'Interview prep',
'Company research',
'Network / referral',
'Salary negotiation',
] as const
const feedbackDismissed = ref(false)
const feedbackSaved = ref(!!props.job.hired_feedback)
const feedbackText = ref('')
const feedbackFactors = ref<string[]>([])
const feedbackSaving = ref(false)
const showFeedbackWidget = computed(() =>
props.job.status === 'hired' && !feedbackDismissed.value && !feedbackSaved.value
)
async function saveFeedback() {
feedbackSaving.value = true
const { error } = await useApiFetch(`/api/jobs/${props.job.id}/hired-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ what_helped: feedbackText.value, factors: feedbackFactors.value }),
})
feedbackSaving.value = false
if (!error) feedbackSaved.value = true
}
</script>
<template>
<article
class="interview-card"
:class="{ 'interview-card--focused': focused }"
:style="{ '--card-accent': columnColor }"
tabindex="0"
:aria-label="`${job.title} at ${job.company}`"
@keydown.enter="emit('prep', job.id)"
@keydown.m.exact="emit('move', job.id)"
>
<div class="card-body">
<div class="card-title">{{ job.title }}</div>
<div class="card-company">
{{ job.company }}
<span v-if="job.salary" class="card-salary">· {{ job.salary }}</span>
</div>
<div class="card-badges">
<span class="score-badge" :class="scoreClass">{{ scoreLabel }}</span>
<span v-if="job.is_remote" class="remote-badge">Remote</span>
</div>
<div v-if="interviewDateLabel" class="date-chip">
{{ dateChipIcon }} {{ interviewDateLabel }}
</div>
<!-- Inline date picker for phone_screen and interviewing -->
<div v-if="DATE_STAGES.has(job.status)" class="date-picker-wrap">
<input
type="datetime-local"
class="date-picker"
:value="toDatetimeLocal(job.interview_date)"
:aria-label="`Interview date for ${job.title}`"
@change="onDateChange(($event.target as HTMLInputElement).value)"
@click.stop
/>
</div>
</div>
<footer class="card-footer">
<button class="card-action" @click.stop="emit('move', job.id)">Move to </button>
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('research', job.id)">🔍 Research</button>
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('prep', job.id)">Prep </button>
<button
v-if="['survey', 'phone_screen', 'interviewing', 'offer'].includes(job.status)"
class="card-action"
@click.stop="emit('survey', job.id)"
>Survey </button>
<!-- Calendar push phone_screen and interviewing only -->
<button
v-if="DATE_STAGES.has(job.status)"
class="card-action card-action--cal"
:class="`card-action--cal-${calPushStatus}`"
:disabled="calPushStatus === 'loading'"
@click.stop="pushCalendar"
:aria-label="`Push ${job.title} to calendar`"
>
<span v-if="calPushStatus === 'loading'"></span>
<span v-else-if="calPushStatus === 'synced'">Synced </span>
<span v-else-if="calPushStatus === 'failed'">Failed </span>
<span v-else>📅 Calendar</span>
</button>
</footer>
<!-- 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],
}"
>
<div class="signal-header">
<span class="signal-label">
📧 <strong>{{ SIGNAL_META[sig.stage_signal].label.replace('Move to ', '') }}</strong>
</span>
<span class="signal-subject">{{ sig.subject.slice(0, 60) }}{{ sig.subject.length > 60 ? '…' : '' }}</span>
<div class="signal-header-actions">
<button class="btn-signal-read" @click.stop="toggleBodyExpand(sig.id)">
{{ expandedSignalIds.has(sig.id) ? '▾ Hide' : '▸ Read' }}
</button>
<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}`"
> Move</button>
<button
class="btn-signal-dismiss"
@click.stop="dismissSignal(sig)"
aria-label="Dismiss signal"
></button>
</div>
</div>
<!-- Expanded body + reclassify chips -->
<div v-if="expandedSignalIds.has(sig.id)" class="signal-body-expanded">
<div v-if="sig.from_addr" class="signal-from">From: {{ sig.from_addr }}</div>
<div v-if="sig.body" class="signal-body-text">{{ sig.body }}</div>
<div v-else class="signal-body-empty">No email body available.</div>
<div class="signal-reclassify">
<span class="signal-reclassify-label">Re-classify:</span>
<button
v-for="chip in RECLASSIFY_CHIPS"
:key="chip.value"
class="btn-chip"
:class="{ 'btn-chip-active': sig.stage_signal === chip.value }"
@click.stop="reclassifySignal(sig, chip.value)"
>{{ chip.label }}</button>
</div>
</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>
<!-- Hired feedback widget -->
<div v-if="showFeedbackWidget" class="hired-feedback" @click.stop>
<div class="hired-feedback__header">
<span class="hired-feedback__title">What helped you land this role?</span>
<button class="hired-feedback__dismiss" @click="feedbackDismissed = true" aria-label="Dismiss feedback"></button>
</div>
<div class="hired-feedback__factors">
<label
v-for="factor in FEEDBACK_FACTORS"
:key="factor"
class="hired-feedback__factor"
>
<input type="checkbox" :value="factor" v-model="feedbackFactors" />
{{ factor }}
</label>
</div>
<textarea
v-model="feedbackText"
class="hired-feedback__textarea"
placeholder="Anything else that made the difference…"
rows="2"
/>
<button
class="hired-feedback__save"
:disabled="feedbackSaving"
@click="saveFeedback"
>{{ feedbackSaving ? 'Saving…' : 'Save reflection' }}</button>
</div>
<div v-else-if="job.status === 'hired' && feedbackSaved" class="hired-feedback hired-feedback--saved">
Reflection saved.
</div>
</article>
</template>
<style scoped>
.interview-card {
background: var(--color-surface-raised);
border-radius: 10px;
border-left: 4px solid var(--card-accent, var(--color-border));
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07);
cursor: pointer;
outline: none;
transition: box-shadow 150ms;
}
.interview-card--focused,
.interview-card:focus-visible {
box-shadow: 0 0 0 3px var(--card-accent, var(--color-primary));
}
.card-body {
padding: 10px 12px 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.card-title {
font-weight: 700;
font-size: 0.875rem;
color: var(--color-text);
line-height: 1.2;
}
.card-company {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.card-salary {
color: var(--color-text-muted);
}
.card-badges {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 2px;
}
.score-badge {
border-radius: 99px;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 700;
}
.score--high {
background: color-mix(in srgb, var(--color-success) 18%, var(--color-surface-raised));
color: var(--color-success);
}
.score--mid {
background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface-raised));
color: var(--color-warning);
}
.score--low {
background: color-mix(in srgb, var(--color-error) 18%, var(--color-surface-raised));
color: var(--color-error);
}
.remote-badge {
border-radius: 99px;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 700;
background: color-mix(in srgb, var(--color-info) 14%, var(--color-surface-raised));
color: var(--color-info);
}
.date-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: color-mix(in srgb, var(--color-info) 12%, var(--color-surface-raised));
color: var(--color-info);
border-radius: 6px;
padding: 3px 8px;
font-size: 0.7rem;
font-weight: 700;
margin-top: 2px;
align-self: flex-start;
}
.date-picker-wrap {
margin-top: 4px;
}
.date-picker {
width: 100%;
font-size: 0.72rem;
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 6px);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
transition: border-color var(--transition, 150ms);
}
.date-picker:hover,
.date-picker:focus {
border-color: var(--color-info);
}
.date-picker:focus-visible {
outline: 2px solid var(--color-info);
outline-offset: 2px;
}
.card-footer {
border-top: 1px solid var(--color-border-light);
padding: 6px 10px;
display: flex;
align-items: center;
justify-content: space-between;
background: color-mix(in srgb, var(--color-surface) 60%, transparent);
}
.card-action {
background: none;
border: none;
cursor: pointer;
font-size: 0.7rem;
font-weight: 700;
color: var(--color-info);
padding: 2px 4px;
border-radius: 4px;
}
.card-action:hover {
background: var(--color-surface);
}
.card-action--cal {
margin-left: auto;
min-width: 72px;
text-align: center;
transition: background var(--transition, 150ms), color var(--transition, 150ms);
}
.card-action--cal-synced {
color: var(--color-success);
}
.card-action--cal-failed {
color: var(--color-error);
}
.card-action--cal:disabled {
opacity: 0.6;
cursor: default;
}
.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); }
.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-signal-read {
background: none; border: none; color: var(--color-text-muted); font-size: 0.82em;
cursor: pointer; padding: 2px 6px; white-space: nowrap;
}
.signal-header {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.signal-header-actions {
margin-left: auto; display: flex; gap: 6px; align-items: center;
}
.signal-body-expanded {
margin-top: 8px; font-size: 0.8em; border-top: 1px dashed var(--color-border);
padding-top: 8px;
}
.signal-from {
color: var(--color-text-muted); margin-bottom: 4px;
}
.signal-body-text {
white-space: pre-wrap; color: var(--color-text); line-height: 1.5;
max-height: 200px; overflow-y: auto;
}
.signal-body-empty {
color: var(--color-text-muted); font-style: italic;
}
.signal-reclassify {
display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px;
}
.signal-reclassify-label {
font-size: 0.75em; color: var(--color-text-muted);
}
.btn-chip {
background: var(--color-surface); color: var(--color-text-muted);
border: 1px solid var(--color-border); border-radius: 4px;
padding: 2px 7px; font-size: 0.75em; cursor: pointer;
}
.btn-chip:hover {
background: var(--color-hover);
}
.btn-chip-active {
background: var(--color-primary-muted, #e8f0ff);
color: var(--color-primary); border-color: var(--color-primary);
font-weight: 600;
}
.btn-sig-expand {
background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer;
padding: 4px 12px; text-align: left;
}
/* ── Hired feedback widget ── */
.hired-feedback {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-border);
background: rgba(39, 174, 96, 0.04);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.hired-feedback--saved {
font-size: var(--text-sm);
color: var(--color-text-muted);
text-align: center;
padding: var(--space-2) var(--space-4);
}
.hired-feedback__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.hired-feedback__title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-success);
}
.hired-feedback__dismiss {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: var(--text-sm);
padding: 2px 4px;
}
.hired-feedback__factors {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.hired-feedback__factor {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
color: var(--color-text-muted);
cursor: pointer;
}
.hired-feedback__textarea {
width: 100%;
font-size: var(--text-sm);
padding: var(--space-2);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text);
resize: vertical;
box-sizing: border-box;
}
.hired-feedback__textarea:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.hired-feedback__save {
align-self: flex-end;
padding: var(--space-1) var(--space-3);
font-size: var(--text-sm);
background: var(--color-success);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
.hired-feedback__save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>