peregrine/web/src/views/InterviewsView.vue
pyr0ball 293f0aba53
Some checks failed
CI / Backend (Python) (push) Failing after 2m10s
CI / Frontend (Vue) (push) Failing after 57s
Mirror / mirror (push) Failing after 8s
Release / release (push) Failing after 3s
chore(release): v0.9.4
Messaging overhaul: expandable email timeline with lazy body loading,
sticky compose bar replacing always-visible action buttons, layout height
fixed to 100dvh. Accessibility fixes for contrast failures on orange/amber
backgrounds. Theme-aware replacements for hardcoded colors in Interviews,
References, and JobReview. Indeed alert parser, Oracle HCM scraper,
manage.sh compose engine detection.
2026-05-08 13:32:10 -07:00

1049 lines
42 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, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useInterviewsStore } from '../stores/interviews'
import type { PipelineJob, PipelineStage } from '../stores/interviews'
import type { StageSignal } from '../stores/interviews'
import { useApiFetch } from '../composables/useApi'
import InterviewCard from '../components/InterviewCard.vue'
import MoveToSheet from '../components/MoveToSheet.vue'
import CompanyResearchModal from '../components/CompanyResearchModal.vue'
import HintChip from '../components/HintChip.vue'
import { useAppConfigStore } from '../stores/appConfig'
const config = useAppConfigStore()
const router = useRouter()
const store = useInterviewsStore()
// ── Move sheet ────────────────────────────────────────────────────────────────
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
}
async function onMove(stage: PipelineStage, opts: { interview_date?: string; rejection_stage?: string }) {
if (!moveTarget.value) return
const movedJob = moveTarget.value
const wasHired = stage === 'hired'
await store.move(movedJob.id, stage, opts)
moveTarget.value = null
if (wasHired) triggerConfetti()
// Auto-open research modal when moving to phone_screen (mirrors Streamlit behaviour)
if (stage === 'phone_screen') openResearch(movedJob.id, `${movedJob.title} at ${movedJob.company}`)
}
// ── Company research modal ─────────────────────────────────────────────────────
const researchJobId = ref<number | null>(null)
const researchJobTitle = ref('')
const researchAutoGen = ref(false)
function openResearch(jobId: number, jobTitle: string, autoGenerate = true) {
researchJobId.value = jobId
researchJobTitle.value = jobTitle
researchAutoGen.value = autoGenerate
}
function onInterviewCardResearch(jobId: number) {
const job = store.jobs.find(j => j.id === jobId)
if (job) openResearch(jobId, `${job.title} at ${job.company}`, false)
}
// ── 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 APPLIED_PAGE_SIZE = 10
const appliedPage = ref(0)
const allApplied = computed(() => [...store.applied, ...store.survey])
const appliedPageCount = computed(() => Math.ceil(allApplied.value.length / APPLIED_PAGE_SIZE))
const pagedApplied = computed(() =>
allApplied.value.slice(
appliedPage.value * APPLIED_PAGE_SIZE,
(appliedPage.value + 1) * APPLIED_PAGE_SIZE,
)
)
// Clamp page when the list shrinks (e.g. after a move)
watch(allApplied, () => {
if (appliedPage.value >= appliedPageCount.value) appliedPage.value = 0
})
const appliedSignalCount = computed(() =>
[...store.applied, ...store.survey]
.reduce((n, job) => n + (job.stage_signals?.length ?? 0), 0)
)
// ── Signal metadata (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' })
}
const bodyExpandedMap = ref<Record<number, boolean>>({})
function toggleBodyExpand(sigId: number) {
bodyExpandedMap.value = { ...bodyExpandedMap.value, [sigId]: !bodyExpandedMap.value[sigId] }
}
const PRE_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 reclassifyPreSignal(job: PipelineJob, sig: StageSignal, newLabel: StageSignal['stage_signal'] | 'neutral' | 'unrelated' | 'digest') {
if (DISMISS_LABELS.has(newLabel)) {
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}/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
}
}
// ── 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()
}
}
// ── Confetti (easter egg 9.5) ─────────────────────────────────────────────────
const showHiredToast = ref(false)
const confettiCanvas = ref<HTMLCanvasElement | null>(null)
function triggerConfetti() {
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (reducedMotion) {
showHiredToast.value = true
setTimeout(() => { showHiredToast.value = false }, 6000)
return
}
const canvas = confettiCanvas.value
if (!canvas) return
canvas.width = window.innerWidth
canvas.height = window.innerHeight
canvas.style.display = 'block'
const ctx = canvas.getContext('2d')!
const COLORS = ['#c4732a','#1a7a6e','#3b82f6','#f5c518','#e84393','#6ab870']
const particles = Array.from({ length: 120 }, (_, i) => ({
x: Math.random() * canvas.width,
y: -10 - Math.random() * 200,
r: 4 + Math.random() * 6,
color: COLORS[i % COLORS.length],
vx: (Math.random() - 0.5) * 4,
vy: 3 + Math.random() * 4,
angle: Math.random() * 360,
spin: (Math.random() - 0.5) * 8,
}))
let frame = 0
function draw() {
ctx.clearRect(0, 0, canvas!.width, canvas!.height)
particles.forEach(p => {
p.x += p.vx; p.y += p.vy; p.vy += 0.08; p.angle += p.spin
ctx.save()
ctx.translate(p.x, p.y)
ctx.rotate((p.angle * Math.PI) / 180)
ctx.fillStyle = p.color
ctx.fillRect(-p.r / 2, -p.r / 2, p.r, p.r * 1.6)
ctx.restore()
})
frame++
if (frame < 240) requestAnimationFrame(draw)
else canvas!.style.display = 'none'
}
draw()
}
// ── Keyboard navigation ───────────────────────────────────────────────────────
const focusedCol = ref(0)
const focusedCard = ref(0)
const columns = [
{ jobs: () => store.phoneScreen },
{ jobs: () => store.interviewing },
{ jobs: () => store.offerHired },
]
function onKeydown(e: KeyboardEvent) {
if (moveTarget.value) return
const colJobs = columns[focusedCol.value].jobs()
if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') {
e.preventDefault(); focusedCard.value = Math.max(0, focusedCard.value - 1)
} else if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
e.preventDefault(); focusedCard.value = Math.min(colJobs.length - 1, focusedCard.value + 1)
} else if (e.key === 'ArrowLeft' || e.key === '[' || e.key === '4') {
e.preventDefault(); focusedCol.value = Math.max(0, focusedCol.value - 1); focusedCard.value = 0
} else if (e.key === 'ArrowRight' || e.key === ']' || e.key === '6') {
e.preventDefault(); focusedCol.value = Math.min(columns.length - 1, focusedCol.value + 1); focusedCard.value = 0
} else if (e.key === 'm' || e.key === 'M') {
const job = colJobs[focusedCard.value]; if (job) openMove(job.id)
} else if (e.key === 'Enter' || e.key === ' ') {
const job = colJobs[focusedCard.value]; if (job) router.push(`/prep/${job.id}`)
}
}
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 }
})
function daysSince(dateStr: string | null) {
if (!dateStr) return null
return Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000)
}
// ── Rejected analytics section ─────────────────────────────────────────────────
const rejectedExpanded = ref(false)
const REJECTION_STAGES = ['applied', 'phone_screen', 'interviewing', 'offer'] as const
type RejectionStage = typeof REJECTION_STAGES[number]
const REJECTION_STAGE_LABELS: Record<RejectionStage, string> = {
applied: 'Applied',
phone_screen: 'Phone Screen',
interviewing: 'Interviewing',
offer: 'Offer',
}
const rejectedByStage = computed(() => {
const counts: Record<string, number> = {}
for (const job of store.rejected) {
const stage = job.rejection_stage ?? 'applied'
counts[stage] = (counts[stage] ?? 0) + 1
}
return counts
})
function formatRejectionDate(job: PipelineJob): string {
// Use the most recent stage timestamp as rejection date
const candidates = [job.offer_at, job.interviewing_at, job.phone_screen_at, job.applied_at]
for (const ts of candidates) {
if (ts) {
const d = new Date(ts)
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
}
}
return '—'
}
</script>
<template>
<div class="interviews-view">
<HintChip
v-if="config.isDemo"
view-key="interviews"
message="Figma sent an offer — open it to see the hired outcome and post-hire feedback"
/>
<canvas ref="confettiCanvas" class="confetti-canvas" aria-hidden="true" />
<Transition name="toast">
<div v-if="showHiredToast" class="hired-toast" role="alert">
🎉 Congratulations! You got the job!
</div>
</Transition>
<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>
<div v-if="store.error" class="error-banner">{{ store.error }}</div>
<!-- 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 pagedApplied" :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>
<button
v-if="job.status === 'survey'"
class="btn-move-pre"
@click="router.push('/survey/' + job.id)"
>Survey </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)"
:key="sig.id"
class="pre-signal-banner"
:data-color="SIGNAL_META_PRE[sig.stage_signal]?.color"
>
<div class="signal-header">
<span class="signal-label">📧 <strong>{{ SIGNAL_META_PRE[sig.stage_signal]?.label?.replace('Move to ', '') ?? sig.stage_signal }}</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)"
:aria-expanded="bodyExpandedMap[sig.id] ?? false"
:aria-label="(bodyExpandedMap[sig.id] ? 'Hide' : 'Read') + ' email body'">
{{ bodyExpandedMap[sig.id] ? '▾ Hide' : '▸ Read' }}
</button>
<button
class="btn-signal-move"
@click.stop="openMove(job.id, SIGNAL_META_PRE[sig.stage_signal]?.stage)"
:aria-label="`Move ${job.title} ${SIGNAL_META_PRE[sig.stage_signal]?.label ?? 'Move'}`"
> Move</button>
<button class="btn-signal-dismiss" @click.stop="dismissPreSignal(job, sig)" aria-label="Dismiss signal"></button>
</div>
</div>
<!-- Expanded body + reclassify chips -->
<div v-if="bodyExpandedMap[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 PRE_RECLASSIFY_CHIPS"
:key="chip.value"
class="btn-chip"
:class="{ 'btn-chip-active': sig.stage_signal === chip.value }"
@click.stop="reclassifyPreSignal(job, sig, chip.value)"
>{{ chip.label }}</button>
</div>
</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` }}</button>
</template>
</template>
<!-- Pagination -->
<div v-if="appliedPageCount > 1" class="pre-list-pagination">
<button
class="btn-page"
:disabled="appliedPage === 0"
@click="appliedPage--"
aria-label="Previous page"
></button>
<span class="page-indicator">{{ appliedPage + 1 }} / {{ appliedPageCount }}</span>
<button
class="btn-page"
:disabled="appliedPage >= appliedPageCount - 1"
@click="appliedPage++"
aria-label="Next page"
></button>
</div>
</div>
</section>
<!-- Kanban columns -->
<section class="kanban" aria-label="Interview pipeline">
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 0 }" aria-label="Phone Screen">
<div class="col-header" style="color: var(--status-phone)">
📞 Phone Screen <span class="col-count">{{ store.phoneScreen.length }}</span>
</div>
<div v-if="store.phoneScreen.length === 0" class="col-empty">
<div class="empty-bird-wrap"><span class="empty-bird-float">🦅</span></div>
<p class="empty-msg">No phone screens yet.<br>Move an applied job here when a recruiter reaches out.</p>
</div>
<InterviewCard v-for="(job, i) in store.phoneScreen" :key="job.id" :job="job"
:focused="focusedCol === 0 && focusedCard === i"
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)"
@research="onInterviewCardResearch" />
</div>
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 1 }" aria-label="Interviewing">
<div class="col-header" style="color: var(--color-info)">
🎯 Interviewing <span class="col-count">{{ store.interviewing.length }}</span>
</div>
<div v-if="store.interviewing.length === 0" class="col-empty">
<div class="empty-bird-wrap"><span class="empty-bird-float">🦅</span></div>
<p class="empty-msg">Phone screen going well?<br>Move it here when you've got a real interview scheduled.</p>
</div>
<InterviewCard v-for="(job, i) in store.interviewing" :key="job.id" :job="job"
:focused="focusedCol === 1 && focusedCard === i"
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)"
@research="onInterviewCardResearch" />
</div>
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 2 }" aria-label="Offer and Hired">
<div class="col-header" style="color: var(--status-offer)">
📜 Offer / Hired <span class="col-count">{{ store.offerHired.length }}</span>
</div>
<div v-if="store.offerHired.length === 0" class="col-empty">
<div class="empty-bird-wrap"><span class="empty-bird-float">🦅</span></div>
<p class="empty-msg">This is where offers land.<br>You've got this. 🙌</p>
</div>
<InterviewCard v-for="(job, i) in store.offerHired" :key="job.id" :job="job"
:focused="focusedCol === 2 && focusedCard === i"
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)"
@research="onInterviewCardResearch" />
</div>
</section>
<!-- Rejected analytics section -->
<section v-if="store.rejected.length > 0" class="rejected-section" aria-label="Rejected jobs">
<button
class="rejected-toggle"
:aria-expanded="rejectedExpanded"
aria-controls="rejected-body"
@click="rejectedExpanded = !rejectedExpanded"
>
<span class="rejected-chevron" :class="{ 'is-expanded': rejectedExpanded }"></span>
<span class="rejected-toggle-label">Rejected ({{ store.rejected.length }})</span>
</button>
<div
id="rejected-body"
class="rejected-body"
:class="{ 'is-expanded': rejectedExpanded }"
>
<!-- Stage breakdown stats bar -->
<div class="rejected-stats-bar" role="list" aria-label="Rejections by stage">
<div
v-for="stage in REJECTION_STAGES"
:key="stage"
class="rejected-stat-chip"
:class="{ 'rejected-stat-chip--active': (rejectedByStage[stage] ?? 0) > 0 }"
role="listitem"
>
<span class="rejected-stat-num">{{ rejectedByStage[stage] ?? 0 }}</span>
<span class="rejected-stat-lbl">{{ REJECTION_STAGE_LABELS[stage] }}</span>
</div>
</div>
<!-- Flat list of rejected jobs -->
<div class="rejected-list">
<div v-for="job in store.rejected" :key="job.id" class="rejected-row">
<div class="rejected-row-info">
<span class="rejected-job-title">{{ job.title }}</span>
<span class="rejected-job-company">{{ job.company }}</span>
</div>
<div class="rejected-row-meta">
<span
class="rejected-stage-badge"
:class="`rejected-stage-badge--${job.rejection_stage ?? 'applied'}`"
>{{ REJECTION_STAGE_LABELS[job.rejection_stage as RejectionStage] ?? job.rejection_stage ?? 'Applied' }}</span>
<span class="rejected-date">{{ formatRejectionDate(job) }}</span>
<button class="btn-unrej" @click="openMove(job.id)" :aria-label="`Move ${job.title}`">Move </button>
</div>
</div>
</div>
</div>
</section>
<MoveToSheet
v-if="moveTarget"
:currentStatus="moveTarget.status"
:jobTitle="`${moveTarget.title} at ${moveTarget.company}`"
:preSelectedStage="movePreSelected"
@move="onMove"
@close="moveTarget = null; movePreSelected = undefined"
/>
<CompanyResearchModal
v-if="researchJobId !== null"
:jobId="researchJobId"
:jobTitle="researchJobTitle"
:autoGenerate="researchAutoGen"
@close="researchJobId = null"
/>
</div>
</template>
<style scoped>
.interviews-view {
padding: var(--space-4) var(--space-6) var(--space-12);
max-width: 1400px; margin: 0 auto; position: relative;
}
.confetti-canvas { position: fixed; inset: 0; z-index: 300; pointer-events: none; display: none; }
.hired-toast {
position: fixed; bottom: var(--space-8); left: 50%; transform: translateX(-50%);
background: var(--color-success); color: #fff;
padding: var(--space-3) var(--space-6); border-radius: 12px;
font-weight: 700; font-size: 1.1rem; z-index: 400;
box-shadow: 0 4px 20px rgba(0,0,0,.2);
}
.toast-enter-active, .toast-leave-active { transition: all 400ms ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(20px); }
.view-header { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-6); }
.view-title { font-size: 1.5rem; font-weight: 700; margin: 0; }
.btn-refresh { background: none; border: 1px solid var(--color-border); border-radius: 6px; cursor: pointer; padding: 4px 10px; font-size: 1rem; color: var(--color-text-muted); }
.error-banner { background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); color: var(--color-error); padding: var(--space-2) var(--space-3); border-radius: 8px; margin-bottom: var(--space-4); }
/* 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: var(--app-accent); }
/* 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 { background: var(--color-surface); border-radius: 10px; padding: var(--space-3) var(--space-4); margin-bottom: var(--space-6); }
.pre-list-toggle-title { display: flex; align-items: center; }
.pre-list-empty { display: flex; align-items: center; gap: var(--space-2); font-size: 0.85rem; color: var(--color-text-muted); padding: var(--space-2) 0; }
.pre-list-row { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-top: 1px solid var(--color-border-light); gap: var(--space-3); }
.pre-row-info { display: flex; align-items: center; gap: var(--space-2); flex: 1; min-width: 0; }
.pre-row-title { font-weight: 600; font-size: 0.875rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pre-row-company { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; }
.survey-badge { background: color-mix(in srgb, var(--status-phone) 12%, var(--color-surface-raised)); color: var(--status-phone); border-radius: 99px; padding: 1px 7px; font-size: 0.7rem; font-weight: 700; }
.pre-row-meta { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; }
.pre-row-days { font-size: 0.75rem; color: var(--color-text-muted); }
.btn-move-pre { background: none; border: 1px solid var(--color-border); border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; font-weight: 700; color: var(--color-info); cursor: pointer; }
/* 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: color-mix(in srgb, var(--color-warning) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-warning) 40%, transparent); }
.pre-signal-banner[data-color="green"] { background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-success) 40%, transparent); }
.pre-signal-banner[data-color="red"] { background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-error) 40%, transparent); }
.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: var(--color-text-inverse);
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;
}
.kanban {
display: grid;
grid-template-columns: repeat(3, minmax(280px, 1fr));
gap: var(--space-5);
margin-bottom: var(--space-6);
}
@media (max-width: 768px) { .kanban { grid-template-columns: 1fr; } }
.kanban-col {
background: var(--color-surface); border-radius: 10px;
padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3);
transition: box-shadow 150ms;
min-height: 200px;
}
.kanban-col--focused { box-shadow: 0 0 0 2px var(--color-primary); }
.col-header {
font-size: 0.8rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .05em; display: flex; align-items: center; justify-content: space-between;
}
.col-count { background: rgba(0,0,0,.08); border-radius: 99px; padding: 1px 8px; font-size: 0.75rem; font-weight: 700; color: var(--color-text-muted); }
.col-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: var(--space-2); padding: var(--space-6) var(--space-3); text-align: center;
}
.empty-bird-wrap { background: var(--color-surface-alt); border-radius: 50%; width: 52px; height: 52px; display: flex; align-items: center; justify-content: center; }
.empty-bird-float { font-size: 1.75rem; animation: float 3s ease-in-out infinite; }
@keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
.empty-msg { font-size: 0.8rem; color: var(--color-text-muted); line-height: 1.5; }
/* Rejected analytics section */
.rejected-section {
border: 1px solid color-mix(in srgb, var(--color-error) 25%, var(--color-border-light));
border-radius: 10px;
overflow: hidden;
margin-bottom: var(--space-4);
}
.rejected-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-3) var(--space-4);
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface));
border: none;
cursor: pointer;
font-weight: 700;
font-size: 0.85rem;
color: var(--color-error);
text-align: left;
}
.rejected-toggle:hover {
background: color-mix(in srgb, var(--color-error) 12%, var(--color-surface));
}
.rejected-chevron {
font-size: 0.75em;
display: inline-block;
transition: transform 200ms;
color: var(--color-error);
}
.rejected-chevron.is-expanded {
transform: rotate(90deg);
}
.rejected-toggle-label {
flex: 1;
}
.rejected-body {
max-height: 0;
overflow: hidden;
transition: max-height 300ms ease;
}
.rejected-body.is-expanded {
max-height: 2000px;
}
@media (prefers-reduced-motion: reduce) {
.rejected-body, .rejected-chevron { transition: none; }
}
/* Stats bar */
.rejected-stats-bar {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-3) var(--space-4) 0;
background: color-mix(in srgb, var(--color-error) 4%, var(--color-surface-raised));
}
.rejected-stat-chip {
display: flex;
flex-direction: column;
align-items: center;
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: 8px;
padding: var(--space-2) var(--space-3);
min-width: 72px;
opacity: 0.5;
transition: opacity 150ms;
}
.rejected-stat-chip--active {
opacity: 1;
border-color: color-mix(in srgb, var(--color-error) 40%, var(--color-border-light));
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface-raised));
}
.rejected-stat-num {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-error);
line-height: 1.1;
}
.rejected-stat-lbl {
font-size: 0.65rem;
color: var(--color-text-muted);
text-align: center;
margin-top: 2px;
}
/* Flat rejected list */
.rejected-list {
padding: var(--space-3) var(--space-4) var(--space-4);
background: color-mix(in srgb, var(--color-error) 4%, var(--color-surface-raised));
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-3);
}
.rejected-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
background: var(--color-surface-raised);
border-radius: 6px;
padding: var(--space-2) var(--space-3);
border-left: 3px solid color-mix(in srgb, var(--color-error) 60%, transparent);
flex-wrap: wrap;
}
.rejected-row-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.rejected-job-title {
font-weight: 600;
font-size: 0.875rem;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rejected-job-company {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.rejected-row-meta {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.rejected-stage-badge {
font-size: 0.68rem;
font-weight: 700;
border-radius: 99px;
padding: 2px 8px;
background: color-mix(in srgb, var(--color-error) 14%, var(--color-surface-raised));
color: var(--color-error);
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
white-space: nowrap;
}
/* Tone down badges for earlier stages */
.rejected-stage-badge--applied {
background: color-mix(in srgb, var(--color-text-muted) 10%, var(--color-surface-raised));
color: var(--color-text-muted);
border-color: var(--color-border-light);
}
.rejected-stage-badge--phone_screen {
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface-raised));
color: var(--color-warning);
border-color: color-mix(in srgb, var(--color-warning) 30%, transparent);
}
.rejected-stage-badge--interviewing {
background: color-mix(in srgb, var(--color-info) 12%, var(--color-surface-raised));
color: var(--color-info);
border-color: color-mix(in srgb, var(--color-info) 30%, transparent);
}
.rejected-stage-badge--offer {
background: color-mix(in srgb, var(--color-error) 14%, var(--color-surface-raised));
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 30%, transparent);
}
.rejected-date {
font-size: 0.72rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.btn-unrej {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 2px 8px;
font-size: 0.75rem;
font-weight: 700;
color: var(--color-info);
cursor: pointer;
}
.btn-unrej:hover {
background: var(--color-surface-alt);
}
@media (max-width: 540px) {
.rejected-row {
flex-direction: column;
align-items: flex-start;
}
.rejected-row-meta {
width: 100%;
justify-content: flex-start;
}
.rejected-stats-bar {
gap: var(--space-1);
}
.rejected-stat-chip {
min-width: 60px;
padding: var(--space-1) var(--space-2);
}
}
.empty-bird { font-size: 1.25rem; }
.pre-list-pagination {
display: flex; align-items: center; justify-content: center; gap: var(--space-2);
padding: 6px 12px; border-top: 1px solid var(--color-border-light);
}
.btn-page {
background: none; border: 1px solid var(--color-border); border-radius: 4px;
color: var(--color-text); font-size: 0.9em; padding: 2px 10px; cursor: pointer;
line-height: 1.6;
}
.btn-page:disabled {
opacity: 0.35; cursor: default;
}
.btn-page:not(:disabled):hover {
background: var(--color-surface-raised);
}
.page-indicator {
font-size: 0.8em; color: var(--color-text-muted); min-width: 40px; text-align: center;
}
</style>