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

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

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

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

316 lines
7.9 KiB
Vue

<template>
<article
class="job-card"
:class="{
'job-card--expanded': expanded,
'job-card--shimmer': isPerfectMatch,
}"
:aria-label="`${job.title} at ${job.company}`"
>
<!-- Score badge + remote badge + shadow badge -->
<div class="job-card__badges">
<span
v-if="job.match_score !== null"
class="score-badge"
:class="scoreBadgeClass"
:aria-label="`${job.match_score}% match`"
>
{{ job.match_score }}%
</span>
<span v-if="job.is_remote" class="remote-badge">Remote</span>
<span
v-if="job.shadow_score === 'shadow'"
class="shadow-badge shadow-badge--shadow"
:title="`Posted 30+ days before discovery — may already be filled`"
aria-label="Possible shadow listing: posted long before discovery"
>Ghost post</span>
<span
v-else-if="job.shadow_score === 'stale'"
class="shadow-badge shadow-badge--stale"
:title="`Posted 14+ days before discovery — listing may be stale`"
aria-label="Stale listing: posted over 2 weeks before discovery"
>Stale</span>
</div>
<!-- Title + company -->
<h2 class="job-card__title">{{ job.title }}</h2>
<div class="job-card__company">
<span>{{ job.company }}</span>
<span v-if="job.location" class="job-card__sep" aria-hidden="true"> · </span>
<span v-if="job.location" class="job-card__location">{{ job.location }}</span>
</div>
<!-- Salary -->
<div v-if="job.salary" class="job-card__salary">{{ job.salary }}</div>
<!-- Description -->
<div class="job-card__desc" :class="{ 'job-card__desc--clamped': !expanded }">
{{ descriptionText }}
</div>
<!-- Expand/collapse -->
<button
v-if="job.description && job.description.length > DESC_LIMIT"
class="job-card__expand-btn"
:aria-expanded="expanded"
@click.stop="$emit(expanded ? 'collapse' : 'expand')"
>
{{ expanded ? 'Show less ▲' : 'Show more ▼' }}
</button>
<!-- Keyword gaps -->
<div v-if="gaps.length > 0" class="job-card__gaps">
<span class="job-card__gaps-label">Missing keywords:</span>
<span v-for="kw in gaps.slice(0, 5)" :key="kw" class="gap-pill">{{ kw }}</span>
<span v-if="gaps.length > 5" class="job-card__gaps-more">+{{ gaps.length - 5 }} more</span>
</div>
<!-- Footer: source + date -->
<div class="job-card__footer">
<a
v-if="job.url"
:href="job.url"
class="job-card__url"
target="_blank"
rel="noopener noreferrer"
@click.stop
>View listing </a>
<span class="job-card__date">{{ formattedDate }}</span>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Job } from '../stores/review'
const props = defineProps<{
job: Job
expanded: boolean
}>()
defineEmits<{ expand: []; collapse: [] }>()
const DESC_LIMIT = 300
const isPerfectMatch = computed(() => (props.job.match_score ?? 0) >= 95)
const scoreBadgeClass = computed(() => {
const s = props.job.match_score ?? 0
if (s >= 80) return 'score-badge--high'
if (s >= 60) return 'score-badge--mid'
return 'score-badge--low'
})
const gaps = computed<string[]>(() => {
if (!props.job.keyword_gaps) return []
try { return JSON.parse(props.job.keyword_gaps) as string[] }
catch { return [] }
})
const descriptionText = computed(() => {
const d = props.job.description ?? ''
return !props.expanded && d.length > DESC_LIMIT
? d.slice(0, DESC_LIMIT) + '…'
: d
})
const formattedDate = computed(() => {
if (!props.job.date_found) return ''
const d = new Date(props.job.date_found)
const days = Math.floor((Date.now() - d.getTime()) / 86400000)
if (days === 0) return 'Today'
if (days === 1) return 'Yesterday'
if (days < 7) return `${days}d ago`
if (days < 30) return `${Math.floor(days / 7)}w ago`
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
})
</script>
<style scoped>
.job-card {
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-3);
background: var(--color-surface-raised);
border-radius: var(--radius-card, 1rem);
user-select: none;
}
/* Perfect match shimmer — easter egg 9.4 */
.job-card--shimmer {
background: linear-gradient(
105deg,
var(--color-surface-raised) 30%,
rgba(251, 210, 60, 0.25) 50%,
var(--color-surface-raised) 70%
);
background-size: 300% auto;
animation: shimmer-sweep 1.8s ease 2;
}
@keyframes shimmer-sweep {
0% { background-position: 100% center; }
100% { background-position: -100% center; }
}
@media (prefers-reduced-motion: reduce) {
.job-card--shimmer { animation: none; }
}
.job-card__badges {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.score-badge {
display: inline-flex;
align-items: center;
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 700;
font-family: var(--font-mono);
}
.score-badge--high { background: rgba(39, 174, 96, 0.15); color: var(--score-high); }
.score-badge--mid { background: rgba(212, 137, 26, 0.15); color: var(--score-mid); }
.score-badge--low { background: rgba(192, 57, 43, 0.15); color: var(--score-low); }
.remote-badge {
display: inline-flex;
align-items: center;
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
background: var(--app-primary-light);
color: var(--app-primary);
}
.shadow-badge {
display: inline-flex;
align-items: center;
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
cursor: help;
}
.shadow-badge--shadow {
background: rgba(99, 99, 99, 0.15);
color: var(--color-text-muted);
border: 1px solid rgba(99, 99, 99, 0.3);
}
.shadow-badge--stale {
background: rgba(212, 137, 26, 0.12);
color: var(--score-mid);
border: 1px solid rgba(212, 137, 26, 0.25);
}
.job-card__title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
line-height: 1.25;
}
.job-card__company {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
}
.job-card__sep { color: var(--color-border); }
.job-card__location { font-weight: 400; }
.job-card__salary {
font-size: var(--text-sm);
color: var(--color-success);
font-weight: 600;
}
.job-card__desc {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.job-card__desc--clamped {
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
}
.job-card__expand-btn {
align-self: flex-start;
background: transparent;
border: none;
color: var(--app-primary);
font-size: var(--text-xs);
cursor: pointer;
padding: 0;
font-weight: 600;
transition: opacity 150ms ease;
}
.job-card__expand-btn:hover { opacity: 0.7; }
.job-card__gaps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.job-card__gaps-label {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-weight: 600;
}
.gap-pill {
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
background: var(--color-surface-alt);
border: 1px solid var(--color-border-light);
color: var(--color-text-muted);
}
.job-card__gaps-more {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.job-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: var(--space-1);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border-light);
}
.job-card__url {
font-size: var(--text-xs);
color: var(--app-primary);
text-decoration: none;
font-weight: 600;
transition: opacity 150ms ease;
}
.job-card__url:hover { opacity: 0.7; }
.job-card__date {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
</style>