Skip to content

Commit 9d60aaf

Browse files
authored
Merge pull request #124 from reqcore-inc/feat/source-tracking
feat: add tracking link schemas for creation, update, and querying
2 parents 0632620 + 88489e6 commit 9d60aaf

41 files changed

Lines changed: 10649 additions & 513 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ playwright/.cache/
3131

3232

3333
*.code-workspace
34+
35+
# Snyk Security Extension - AI Rules (auto-generated)
36+
.github/instructions/snyk_rules.instructions.md

app/components/AppTopBar.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,14 @@ const jobTabs = computed(() => {
119119
// Main navigation
120120
// ─────────────────────────────────────────────
121121
122-
const mainNav = [
122+
const mainNav: Array<{ label: string; to: string; icon: typeof Briefcase; exact: boolean; comingSoon?: boolean }> = [
123123
{ label: 'Dashboard', to: '/dashboard', icon: LayoutDashboard, exact: true },
124124
{ label: 'Jobs', to: '/dashboard/jobs', icon: Briefcase, exact: false },
125125
{ label: 'Candidates', to: '/dashboard/candidates', icon: Users, exact: false },
126126
{ label: 'Applications', to: '/dashboard/applications', icon: FileText, exact: false },
127127
{ label: 'Interviews', to: '/dashboard/interviews', icon: Calendar, exact: false },
128128
{ label: 'Timeline', to: '/dashboard/timeline', icon: History, exact: true },
129-
{ label: 'Source Tracking', to: '/dashboard/source-tracking', icon: Radio, exact: true, comingSoon: true },
129+
{ label: 'Source Tracking', to: '/dashboard/source-tracking', icon: Radio, exact: true },
130130
{ label: 'AI Analysis', to: '/dashboard/ai-analysis', icon: Sparkles, exact: true },
131131
{ label: 'Settings', to: '/dashboard/settings', icon: Settings, exact: false },
132132
]

app/components/CandidateDetailSidebar.vue

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import {
33
X, User, Calendar, Clock, Hash, MessageSquare, FileText,
44
ExternalLink, Mail, Phone, Upload, Download, Eye, Trash2,
5-
ArrowLeft, AlertTriangle, Brain,
5+
ArrowLeft, AlertTriangle, Brain, History,
66
} from 'lucide-vue-next'
77
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'
88
@@ -34,7 +34,7 @@ const hasSubNav = computed(() => {
3434
// Tabs
3535
// ─────────────────────────────────────────────
3636
37-
const activeTab = ref<'overview' | 'documents' | 'responses' | 'ai_analysis'>('overview')
37+
const activeTab = ref<'overview' | 'documents' | 'responses' | 'ai_analysis' | 'timeline'>('overview')
3838
3939
// ─────────────────────────────────────────────
4040
// Fetch application detail
@@ -288,12 +288,109 @@ function onKeydown(e: KeyboardEvent) {
288288
onMounted(() => window.addEventListener('keydown', onKeydown))
289289
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
290290
291+
// ─────────────────────────────────────────────
292+
// Timeline data for the candidate
293+
// ─────────────────────────────────────────────
294+
295+
interface TimelineEntry {
296+
id: string
297+
action: string
298+
resourceType: string
299+
resourceId: string
300+
metadata: Record<string, unknown> | null
301+
createdAt: string
302+
actorName: string | null
303+
actorEmail: string | null
304+
resourceName: string | null
305+
jobTitle: string | null
306+
candidateName: string | null
307+
}
308+
309+
const timelineItems = ref<TimelineEntry[]>([])
310+
const timelineLoading = ref(false)
311+
const timelineError = ref<string | null>(null)
312+
const timelineLoaded = ref(false)
313+
314+
const timelineActionLabels: Record<string, string> = {
315+
created: 'Created',
316+
updated: 'Updated',
317+
deleted: 'Deleted',
318+
status_changed: 'Status changed',
319+
comment_added: 'Comment added',
320+
scored: 'Scored',
321+
scheduled: 'Scheduled',
322+
}
323+
324+
function formatTimelineDate(dateStr: string) {
325+
const d = new Date(dateStr)
326+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
327+
}
328+
329+
function getTimelineActionColor(action: string): string {
330+
switch (action) {
331+
case 'created': return 'bg-green-500'
332+
case 'status_changed': return 'bg-blue-500'
333+
case 'updated': return 'bg-amber-500'
334+
case 'deleted': return 'bg-danger-500'
335+
case 'comment_added': return 'bg-violet-500'
336+
case 'scored': return 'bg-teal-500'
337+
case 'scheduled': return 'bg-brand-500'
338+
default: return 'bg-surface-400'
339+
}
340+
}
341+
342+
function describeTimelineItem(item: TimelineEntry): string {
343+
const actor = item.actorName ?? item.actorEmail ?? 'System'
344+
const action = timelineActionLabels[item.action] ?? item.action
345+
const resource = item.resourceType
346+
347+
if (item.action === 'status_changed' && item.metadata) {
348+
const from = item.metadata.from_status ?? item.metadata.fromStatus
349+
const to = item.metadata.to_status ?? item.metadata.toStatus
350+
if (from && to) return `${actor} changed ${resource} status from ${from} to ${to}`
351+
}
352+
353+
if (item.action === 'scored' && item.metadata) {
354+
const score = item.metadata.score
355+
if (score != null) return `${actor} scored ${resource} — ${score} pts`
356+
}
357+
358+
return `${actor} ${action.toLowerCase()} ${resource}`
359+
}
360+
361+
async function loadTimeline() {
362+
if (!candidateId.value) return
363+
timelineLoading.value = true
364+
timelineError.value = null
365+
try {
366+
const result = await $fetch<{ items: TimelineEntry[] }>('/api/activity-log/candidate-timeline', {
367+
query: { candidateId: candidateId.value },
368+
})
369+
timelineItems.value = result.items
370+
timelineLoaded.value = true
371+
} catch (err: any) {
372+
timelineError.value = err?.data?.statusMessage ?? 'Failed to load timeline'
373+
} finally {
374+
timelineLoading.value = false
375+
}
376+
}
377+
378+
// Load timeline data lazily when tab is selected
379+
watch(activeTab, (tab) => {
380+
if (tab === 'timeline' && !timelineLoaded.value && candidateId.value) {
381+
loadTimeline()
382+
}
383+
})
384+
291385
// Reset state when switching to a different application
292386
watch(() => props.applicationId, () => {
293387
isEditingNotes.value = false
294388
activeTab.value = 'overview'
295389
uploadError.value = null
296390
showDocDeleteConfirm.value = null
391+
timelineItems.value = []
392+
timelineLoaded.value = false
393+
timelineError.value = null
297394
closePreview()
298395
})
299396
@@ -432,6 +529,16 @@ function formatInterviewDate(dateStr: string) {
432529
<Brain class="size-3.5" />
433530
AI Analysis
434531
</button>
532+
<button
533+
class="cursor-pointer px-3 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px inline-flex items-center gap-1.5"
534+
:class="activeTab === 'timeline'
535+
? 'border-brand-600 text-brand-600'
536+
: 'border-transparent text-surface-500 hover:text-surface-700 hover:border-surface-300 dark:hover:text-surface-300'"
537+
@click="activeTab = 'timeline'"
538+
>
539+
<History class="size-3.5" />
540+
Timeline
541+
</button>
435542
</div>
436543
</div>
437544

@@ -882,6 +989,82 @@ function formatInterviewDate(dateStr: string) {
882989
<ScoreBreakdown :application-id="props.applicationId" @scored="refresh(); emit('updated')" />
883990
</div>
884991

992+
<!-- ═══════════════════════════════════════ -->
993+
<!-- TIMELINE TAB -->
994+
<!-- ═══════════════════════════════════════ -->
995+
<div v-if="activeTab === 'timeline'" class="space-y-1">
996+
<!-- Loading -->
997+
<div v-if="timelineLoading" class="text-center py-12 text-surface-400">
998+
<div class="size-6 rounded-full border-2 border-brand-200 border-t-brand-600 dark:border-brand-800 dark:border-t-brand-400 animate-spin mx-auto mb-3" />
999+
Loading timeline…
1000+
</div>
1001+
1002+
<!-- Error -->
1003+
<div
1004+
v-else-if="timelineError"
1005+
class="rounded-xl border border-danger-200/80 dark:border-danger-800/60 bg-danger-50 dark:bg-danger-950/40 p-5 text-center"
1006+
>
1007+
<AlertTriangle class="size-6 text-danger-400 mx-auto mb-2" />
1008+
<p class="text-sm text-danger-700 dark:text-danger-400">{{ timelineError }}</p>
1009+
<button
1010+
class="mt-3 text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium"
1011+
@click="loadTimeline"
1012+
>
1013+
Retry
1014+
</button>
1015+
</div>
1016+
1017+
<!-- Empty -->
1018+
<div
1019+
v-else-if="timelineItems.length === 0"
1020+
class="rounded-xl border border-surface-200/80 dark:border-surface-800/60 bg-white dark:bg-surface-950 p-8 text-center shadow-sm shadow-surface-900/[0.03] dark:shadow-none"
1021+
>
1022+
<div class="flex size-14 items-center justify-center rounded-2xl bg-surface-100 dark:bg-surface-800/60 mx-auto mb-3">
1023+
<History class="size-6 text-surface-400 dark:text-surface-500" />
1024+
</div>
1025+
<p class="text-sm font-medium text-surface-600 dark:text-surface-300">No activity recorded yet.</p>
1026+
<p class="text-xs text-surface-400 dark:text-surface-500 mt-1">Activity for this candidate will appear here.</p>
1027+
</div>
1028+
1029+
<!-- Timeline list -->
1030+
<div v-else class="relative">
1031+
<!-- Vertical line -->
1032+
<div class="absolute left-[11px] top-2 bottom-2 w-px bg-surface-200 dark:bg-surface-700" />
1033+
1034+
<div
1035+
v-for="item in timelineItems"
1036+
:key="item.id"
1037+
class="relative flex gap-3 py-2.5 group"
1038+
>
1039+
<!-- Dot -->
1040+
<div class="relative z-10 mt-1 shrink-0">
1041+
<div
1042+
class="size-[9px] rounded-full ring-2 ring-white dark:ring-surface-900"
1043+
:class="getTimelineActionColor(item.action)"
1044+
/>
1045+
</div>
1046+
1047+
<!-- Content -->
1048+
<div class="min-w-0 flex-1">
1049+
<p class="text-sm text-surface-700 dark:text-surface-200 leading-snug">
1050+
{{ describeTimelineItem(item) }}
1051+
</p>
1052+
<div class="flex items-center gap-2 mt-0.5">
1053+
<span class="text-[11px] text-surface-400 dark:text-surface-500 tabular-nums">
1054+
{{ formatTimelineDate(item.createdAt) }}
1055+
</span>
1056+
<span
1057+
v-if="item.jobTitle"
1058+
class="text-[10px] text-surface-400 dark:text-surface-500 bg-surface-100 dark:bg-surface-800 rounded px-1.5 py-0.5 truncate max-w-[140px]"
1059+
>
1060+
{{ item.jobTitle }}
1061+
</span>
1062+
</div>
1063+
</div>
1064+
</div>
1065+
</div>
1066+
</div>
1067+
8851068
</template>
8861069
</div>
8871070
</aside>

0 commit comments

Comments
 (0)