peregrine/web/src/views/InterviewsView.vue
pyr0ball 2051880d73
Some checks failed
CI / Backend (Python) (push) Failing after 39s
CI / Frontend (Vue) (push) Successful in 21s
Mirror / mirror (push) Failing after 10s
fix: dark mode CSS token gaps, interview score display, undefined CSS vars
InterviewCard: remove erroneous *100 multiplier from scoreClass and
scoreLabel — match_score is stored as 0-100 in the DB, not 0-1. This
was producing scores like '1490%' for jobs with a 14.9 raw score.

peregrine.css: define --color-hover token for light (rgba(0,0,0,0.06))
and dark (rgba(255,255,255,0.07)). Was undefined, leaving hover states
on InterviewCard, InterviewsView, ReferencesView, ContactsView silent.

InterviewCard + InterviewsView: replace var(--color-primary-muted,#e8f0ff)
with var(--app-primary-light). The hardcoded #e8f0ff fallback is a bright
light-blue that renders on dark backgrounds when the variable is undefined.

WizardTrainingStep: --font-sans → --font-body (correct token name).

ResumeSyncConfirmModal, ResumeLibraryCard, ResumeOptimizerPanel,
resume-review sub-pages: --font-sm → --text-sm across all occurrences.
--font-sm was never defined; most had a 0.875rem fallback (which matches
--text-sm) but the correct token should be referenced directly.
2026-05-18 13:37:08 -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(--app-primary-light);
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>