feat(survey): implement SurveyView with navigation wiring

This commit is contained in:
pyr0ball 2026-03-21 00:27:57 -07:00
parent ea23845c23
commit 4bea0899db
4 changed files with 725 additions and 11 deletions

View file

@ -12,6 +12,7 @@ const props = defineProps<{
const emit = defineEmits<{
move: [jobId: number, preSelectedStage?: PipelineStage]
prep: [jobId: number]
survey: [jobId: number]
}>()
// Signal state
@ -180,6 +181,11 @@ const columnColor = computed(() => {
<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('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>
</footer>
<!-- Signal banners -->
<template v-if="job.stage_signals?.length">

View file

@ -12,6 +12,7 @@ export const router = createRouter({
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
{ path: '/survey/:id', component: () => import('../views/SurveyView.vue') },
{ path: '/settings', component: () => import('../views/SettingsView.vue') },
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
{ path: '/:pathMatch(.*)*', redirect: '/' },

View file

@ -379,6 +379,11 @@ function daysSince(dateStr: string | null) {
<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 -->
@ -461,7 +466,7 @@ function daysSince(dateStr: string | null) {
</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}`)" />
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
</div>
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 1 }" aria-label="Interviewing">
@ -474,7 +479,7 @@ function daysSince(dateStr: string | null) {
</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}`)" />
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
</div>
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 2 }" aria-label="Offer and Hired">
@ -487,7 +492,7 @@ function daysSince(dateStr: string | null) {
</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}`)" />
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
</div>
</section>

View file

@ -1,18 +1,720 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useInterviewsStore } from '../stores/interviews'
import { useSurveyStore } from '../stores/survey'
const route = useRoute()
const router = useRouter()
const interviewsStore = useInterviewsStore()
const surveyStore = useSurveyStore()
const VALID_STAGES = ['survey', 'phone_screen', 'interviewing', 'offer']
const rawId = route.params.id
const jobId = rawId ? parseInt(String(rawId), 10) : NaN
// Redirect if no valid id
if (!jobId || isNaN(jobId)) {
router.replace('/interviews')
}
// UI state
const activeTab = ref<'text' | 'screenshot'>('text')
const textInput = ref('')
const imageB64 = ref<string | null>(null)
const imagePreviewUrl = ref<string | null>(null)
const selectedMode = ref<'quick' | 'detailed'>('quick')
const surveyName = ref('')
const reportedScore = ref('')
const saveSuccess = ref(false)
// Computed job from store
const job = computed(() =>
interviewsStore.jobs.find(j => j.id === jobId) ?? null
)
onMounted(async () => {
if (!jobId || isNaN(jobId)) return
if (interviewsStore.jobs.length === 0) {
await interviewsStore.fetchAll()
}
if (!job.value || !VALID_STAGES.includes(job.value.status)) {
router.replace('/interviews')
return
}
await surveyStore.fetchFor(jobId)
})
onUnmounted(() => {
surveyStore.clear()
})
// Screenshot handling
function handlePaste(e: ClipboardEvent) {
if (!surveyStore.visionAvailable) return
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) loadImageFile(file)
break
}
}
}
function handleDrop(e: DragEvent) {
e.preventDefault()
if (!surveyStore.visionAvailable) return
const file = e.dataTransfer?.files[0]
if (file && file.type.startsWith('image/')) loadImageFile(file)
}
function handleFileUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) loadImageFile(file)
}
function loadImageFile(file: File) {
const reader = new FileReader()
reader.onload = (ev) => {
const result = ev.target?.result as string
imagePreviewUrl.value = result
imageB64.value = result.split(',')[1] // strip "data:image/...;base64,"
}
reader.readAsDataURL(file)
}
function clearImage() {
imageB64.value = null
imagePreviewUrl.value = null
}
// Analysis
const canAnalyze = computed(() =>
activeTab.value === 'text' ? textInput.value.trim().length > 0 : imageB64.value !== null
)
async function runAnalyze() {
const payload: { text?: string; image_b64?: string; mode: 'quick' | 'detailed' } = {
mode: selectedMode.value,
}
if (activeTab.value === 'screenshot' && imageB64.value) {
payload.image_b64 = imageB64.value
} else {
payload.text = textInput.value
}
await surveyStore.analyze(jobId, payload)
}
// Save
async function saveToJob() {
await surveyStore.saveResponse(jobId, {
surveyName: surveyName.value,
reportedScore: reportedScore.value,
image_b64: activeTab.value === 'screenshot' ? imageB64.value ?? undefined : undefined,
})
if (!surveyStore.error) {
saveSuccess.value = true
surveyName.value = ''
reportedScore.value = ''
setTimeout(() => { saveSuccess.value = false }, 3000)
}
}
// Stage label helper
const stageLabel: Record<string, string> = {
survey: 'Survey', phone_screen: 'Phone Screen',
interviewing: 'Interviewing', offer: 'Offer',
}
// History accordion
const historyOpen = ref(false)
function formatDate(iso: string | null): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const expandedHistory = ref<Set<number>>(new Set())
function toggleHistoryEntry(id: number) {
if (expandedHistory.value.has(id)) expandedHistory.value.delete(id)
else expandedHistory.value.add(id)
}
</script>
<template>
<div class="view-placeholder">
<h1>SurveyView</h1>
<p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p>
<div class="survey-layout">
<!-- Sticky context bar -->
<div class="context-bar" v-if="job">
<span class="context-company">{{ job.company }}</span>
<span class="context-sep">·</span>
<span class="context-title">{{ job.title }}</span>
<span class="stage-badge">{{ stageLabel[job.status] ?? job.status }}</span>
</div>
<!-- Load/history error banner -->
<div class="error-banner" v-if="surveyStore.error && !surveyStore.analysis">
{{ surveyStore.error }}
</div>
<div class="survey-content">
<!-- Input card -->
<div class="card">
<div class="tab-bar">
<button
class="tab-btn"
:class="{ active: activeTab === 'text' }"
@click="activeTab = 'text'"
>📝 Paste Text</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'screenshot', disabled: !surveyStore.visionAvailable }"
:aria-disabled="!surveyStore.visionAvailable"
:title="!surveyStore.visionAvailable ? 'Vision service not running — start it with: bash scripts/manage-vision.sh start' : undefined"
@click="surveyStore.visionAvailable && (activeTab = 'screenshot')"
>📷 Screenshot</button>
</div>
<!-- Text tab -->
<div v-if="activeTab === 'text'" class="tab-panel">
<textarea
v-model="textInput"
class="survey-textarea"
placeholder="Paste your survey questions here, e.g.:&#10;Q1: Which best describes your work style?&#10;A. I prefer working alone&#10;B. I thrive in teams&#10;C. Depends on the project"
/>
</div>
<!-- Screenshot tab -->
<div
v-else
class="screenshot-zone"
@paste="handlePaste"
@dragover.prevent
@drop="handleDrop"
tabindex="0"
>
<div v-if="imagePreviewUrl" class="image-preview">
<img :src="imagePreviewUrl" alt="Survey screenshot preview" />
<button class="remove-btn" @click="clearImage"> Remove</button>
</div>
<div v-else class="drop-hint">
<p>Paste (Ctrl+V), drag &amp; drop, or upload a screenshot</p>
<label class="upload-label">
Choose file
<input type="file" accept="image/*" class="file-input" @change="handleFileUpload" />
</label>
</div>
</div>
</div>
<!-- Mode selection -->
<div class="mode-cards">
<button
class="mode-card"
:class="{ selected: selectedMode === 'quick' }"
@click="selectedMode = 'quick'"
>
<span class="mode-icon"></span>
<span class="mode-name">Quick</span>
<span class="mode-desc">Best answer + one-liner per question</span>
</button>
<button
class="mode-card"
:class="{ selected: selectedMode === 'detailed' }"
@click="selectedMode = 'detailed'"
>
<span class="mode-icon">📋</span>
<span class="mode-name">Detailed</span>
<span class="mode-desc">Option-by-option breakdown with reasoning</span>
</button>
</div>
<!-- Analyze button -->
<button
class="analyze-btn"
:disabled="!canAnalyze || surveyStore.loading"
@click="runAnalyze"
>
<span v-if="surveyStore.loading" class="spinner" aria-hidden="true"></span>
{{ surveyStore.loading ? 'Analyzing…' : '🔍 Analyze' }}
</button>
<!-- Analyze error -->
<div class="error-inline" v-if="surveyStore.error && !surveyStore.analysis">
{{ surveyStore.error }}
</div>
<!-- Results card -->
<div class="card results-card" v-if="surveyStore.analysis">
<div class="results-output">{{ surveyStore.analysis.output }}</div>
<div class="save-form">
<input
v-model="surveyName"
class="save-input"
type="text"
placeholder="Survey name (e.g. Culture Fit Round 1)"
/>
<input
v-model="reportedScore"
class="save-input"
type="text"
placeholder="Reported score (e.g. 82% or 4.2/5)"
/>
<button
class="save-btn"
:disabled="surveyStore.saving"
@click="saveToJob"
>
<span v-if="surveyStore.saving" class="spinner" aria-hidden="true"></span>
💾 Save to job
</button>
<div v-if="saveSuccess" class="save-success">Saved!</div>
<div v-if="surveyStore.error" class="error-inline">{{ surveyStore.error }}</div>
</div>
</div>
<!-- History accordion -->
<details class="history-accordion" :open="historyOpen" @toggle="historyOpen = ($event.target as HTMLDetailsElement).open">
<summary class="history-summary">
Survey history ({{ surveyStore.history.length }} response{{ surveyStore.history.length === 1 ? '' : 's' }})
</summary>
<div v-if="surveyStore.history.length === 0" class="history-empty">No responses saved yet.</div>
<div v-else class="history-list">
<div v-for="resp in surveyStore.history" :key="resp.id" class="history-entry">
<button class="history-toggle" @click="toggleHistoryEntry(resp.id)">
<span class="history-name">{{ resp.survey_name ?? 'Survey response' }}</span>
<span class="history-meta">{{ formatDate(resp.received_at) }}{{ resp.reported_score ? ` · ${resp.reported_score}` : '' }}</span>
<span class="history-chevron">{{ expandedHistory.has(resp.id) ? '▲' : '▼' }}</span>
</button>
<div v-if="expandedHistory.has(resp.id)" class="history-detail">
<div class="history-tags">
<span class="tag">{{ resp.mode }}</span>
<span class="tag">{{ resp.source }}</span>
<span v-if="resp.received_at" class="tag">{{ resp.received_at }}</span>
</div>
<div class="history-output">{{ resp.llm_output }}</div>
</div>
</div>
</div>
</details>
</div>
</div>
</template>
<style scoped>
.view-placeholder {
padding: var(--space-8);
max-width: 60ch;
.survey-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.placeholder-note {
color: var(--color-text-muted);
.context-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-6);
height: 40px;
background: var(--color-surface-raised, #f8f9fa);
border-bottom: 1px solid var(--color-border, #e2e8f0);
font-size: 0.875rem;
}
.context-company {
font-weight: 600;
color: var(--color-text, #1a202c);
}
.context-sep {
color: var(--color-text-muted, #718096);
}
.context-title {
color: var(--color-text-muted, #718096);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage-badge {
margin-left: auto;
padding: 2px 8px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
background: var(--color-accent-subtle, #ebf4ff);
color: var(--color-accent, #3182ce);
}
.survey-content {
max-width: 760px;
margin: 0 auto;
padding: var(--space-6);
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.card {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
overflow: hidden;
}
.tab-bar {
display: flex;
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
.tab-btn {
flex: 1;
padding: var(--space-3) var(--space-4);
background: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-muted, #718096);
transition: color 0.15s, background 0.15s;
}
.tab-btn.active {
color: var(--color-accent, #3182ce);
background: var(--color-accent-subtle, #ebf4ff);
font-weight: 600;
}
.tab-btn.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.tab-panel {
padding: var(--space-4);
}
.survey-textarea {
width: 100%;
min-height: 200px;
padding: var(--space-3);
font-family: inherit;
font-size: 0.875rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-sm, 4px);
resize: vertical;
background: var(--color-bg, #fff);
color: var(--color-text, #1a202c);
box-sizing: border-box;
}
.screenshot-zone {
min-height: 160px;
padding: var(--space-6);
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed var(--color-border, #e2e8f0);
margin: var(--space-4);
border-radius: var(--radius-md, 8px);
outline: none;
}
.screenshot-zone:focus {
border-color: var(--color-accent, #3182ce);
}
.drop-hint {
text-align: center;
color: var(--color-text-muted, #718096);
}
.upload-label {
display: inline-block;
margin-top: var(--space-2);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-sm, 4px);
cursor: pointer;
font-size: 0.875rem;
background: var(--color-surface, #fff);
}
.file-input {
display: none;
}
.image-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
width: 100%;
}
.image-preview img {
max-width: 100%;
max-height: 300px;
border-radius: var(--radius-sm, 4px);
}
.remove-btn {
font-size: 0.8rem;
color: var(--color-text-muted, #718096);
background: none;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-sm, 4px);
padding: 2px 8px;
cursor: pointer;
}
.mode-cards {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.mode-card {
display: grid;
grid-template-columns: 2rem 1fr;
grid-template-rows: auto auto;
align-items: center;
gap: 0 var(--space-2);
padding: var(--space-4);
background: var(--color-surface, #fff);
border: 2px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background 0.15s;
}
.mode-card.selected {
border-color: var(--color-accent, #3182ce);
background: var(--color-accent-subtle, #ebf4ff);
}
.mode-icon {
grid-row: 1 / 3;
font-size: 1.25rem;
line-height: 1;
align-self: center;
}
.mode-name {
font-weight: 600;
color: var(--color-text, #1a202c);
line-height: 1.3;
}
.mode-desc {
font-size: 0.8rem;
color: var(--color-text-muted, #718096);
}
.analyze-btn {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--color-accent, #3182ce);
color: #fff;
border: none;
border-radius: var(--radius-md, 8px);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
transition: opacity 0.15s;
}
.analyze-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.results-card {
padding: var(--space-4);
}
.results-output {
white-space: pre-wrap;
font-size: 0.9rem;
line-height: 1.6;
color: var(--color-text, #1a202c);
margin-bottom: var(--space-4);
}
.save-form {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border, #e2e8f0);
}
.save-input {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-sm, 4px);
font-size: 0.875rem;
background: var(--color-bg, #fff);
color: var(--color-text, #1a202c);
}
.save-btn {
align-self: flex-start;
padding: var(--space-2) var(--space-4);
background: var(--color-surface-raised, #f8f9fa);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
gap: var(--space-2);
transition: background 0.15s;
}
.save-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.save-success {
color: var(--color-success, #38a169);
font-size: 0.875rem;
font-weight: 600;
}
.history-accordion {
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
background: var(--color-surface, #fff);
}
.history-summary {
padding: var(--space-3) var(--space-4);
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-muted, #718096);
font-weight: 500;
list-style: none;
}
.history-summary::-webkit-details-marker { display: none; }
.history-empty {
padding: var(--space-4);
color: var(--color-text-muted, #718096);
font-size: 0.875rem;
}
.history-list {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--color-border, #e2e8f0);
}
.history-entry {
background: var(--color-surface, #fff);
}
.history-toggle {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: none;
border: none;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
}
.history-name {
font-weight: 500;
color: var(--color-text, #1a202c);
}
.history-meta {
color: var(--color-text-muted, #718096);
font-size: 0.8rem;
margin-left: auto;
}
.history-chevron {
font-size: 0.7rem;
color: var(--color-text-muted, #718096);
}
.history-detail {
padding: var(--space-3) var(--space-4) var(--space-4);
border-top: 1px solid var(--color-border, #e2e8f0);
}
.history-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.tag {
padding: 1px 6px;
background: var(--color-accent-subtle, #ebf4ff);
color: var(--color-accent, #3182ce);
border-radius: 4px;
font-size: 0.75rem;
}
.history-output {
white-space: pre-wrap;
font-size: 0.875rem;
line-height: 1.6;
color: var(--color-text, #1a202c);
}
.error-banner {
background: var(--color-error-subtle, #fff5f5);
border-bottom: 1px solid var(--color-error, #fc8181);
padding: var(--space-2) var(--space-6);
font-size: 0.875rem;
color: var(--color-error-text, #c53030);
}
.error-inline {
font-size: 0.875rem;
color: var(--color-error-text, #c53030);
padding: var(--space-1) 0;
}
.spinner {
display: inline-block;
width: 1em;
height: 1em;
border: 2px solid rgba(255,255,255,0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.analyze-btn .spinner {
border-color: rgba(255,255,255,0.4);
border-top-color: #fff;
}
.save-btn .spinner {
border-color: rgba(0,0,0,0.15);
border-top-color: var(--color-accent, #3182ce);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>