Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ blob-report/
playwright/.cache/


*.code-workspace
3 changes: 2 additions & 1 deletion app/components/AppTopBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
Sun, Moon, MessageSquarePlus, Settings,
ChevronDown, Menu, X, Users, ChevronLeft,
LayoutDashboard, Calendar, ArrowUpCircle,
Cloud, Server, Sparkles, Radio,
Cloud, Server, Sparkles, Radio, History,
} from 'lucide-vue-next'

const route = useRoute()
Expand Down Expand Up @@ -125,6 +125,7 @@ const mainNav = [
{ label: 'Candidates', to: '/dashboard/candidates', icon: Users, exact: false },
{ label: 'Applications', to: '/dashboard/applications', icon: FileText, exact: false },
{ label: 'Interviews', to: '/dashboard/interviews', icon: Calendar, exact: false },
{ label: 'Timeline', to: '/dashboard/timeline', icon: History, exact: true },
{ label: 'Source Tracking', to: '/dashboard/source-tracking', icon: Radio, exact: true, comingSoon: true },
{ label: 'AI Analysis', to: '/dashboard/ai-analysis', icon: Sparkles, exact: true },
{ label: 'Settings', to: '/dashboard/settings', icon: Settings, exact: false },
Expand Down
25 changes: 16 additions & 9 deletions app/components/CandidateDetailSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const emit = defineEmits<{

const { handlePreviewReadOnlyError } = usePreviewReadOnly()
const toast = useToast()
const { track } = useTrack()

// Detect if the job sub-nav bar is visible (adds 40px / 2.5rem)
const route = useRoute()
Expand Down Expand Up @@ -87,19 +88,19 @@ const transitionLabels: Record<string, string> = {

const transitionClasses: Record<string, string> = {
new: 'border border-surface-300 dark:border-surface-600 text-surface-600 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800',
screening: 'bg-info-600 text-white hover:bg-info-700',
interview: 'bg-warning-600 text-white hover:bg-warning-700',
offer: 'bg-success-600 text-white hover:bg-success-700',
hired: 'bg-success-700 text-white hover:bg-success-800',
screening: 'bg-violet-600 text-white hover:bg-violet-700',
interview: 'bg-amber-600 text-white hover:bg-amber-700',
offer: 'bg-teal-600 text-white hover:bg-teal-700',
hired: 'bg-green-700 text-white hover:bg-green-800',
rejected: 'bg-danger-600 text-white hover:bg-danger-700',
}

const statusBadgeClasses: Record<string, string> = {
new: 'bg-brand-50 text-brand-700 ring-brand-200 dark:bg-brand-950/50 dark:text-brand-300 dark:ring-brand-800',
screening: 'bg-info-50 text-info-700 ring-info-200 dark:bg-info-950/50 dark:text-info-300 dark:ring-info-800',
interview: 'bg-warning-50 text-warning-700 ring-warning-200 dark:bg-warning-950/50 dark:text-warning-300 dark:ring-warning-800',
offer: 'bg-success-50 text-success-700 ring-success-200 dark:bg-success-950/50 dark:text-success-300 dark:ring-success-800',
hired: 'bg-success-100 text-success-800 ring-success-300 dark:bg-success-900/50 dark:text-success-200 dark:ring-success-700',
new: 'bg-blue-50 text-blue-700 ring-blue-200 dark:bg-blue-950/50 dark:text-blue-400 dark:ring-blue-800',
screening: 'bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-950/50 dark:text-violet-400 dark:ring-violet-800',
interview: 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-950/50 dark:text-amber-400 dark:ring-amber-800',
offer: 'bg-teal-50 text-teal-700 ring-teal-200 dark:bg-teal-950/50 dark:text-teal-400 dark:ring-teal-800',
hired: 'bg-green-50 text-green-700 ring-green-200 dark:bg-green-950/50 dark:text-green-400 dark:ring-green-800',
rejected: 'bg-surface-100 text-surface-500 ring-surface-200 dark:bg-surface-800/50 dark:text-surface-400 dark:ring-surface-700',
}

Expand All @@ -117,6 +118,11 @@ async function handleTransition(newStatus: string) {
method: 'PATCH',
body: { status: newStatus },
})
track('sidebar_status_changed', {
application_id: props.applicationId,
from_status: application.value?.status,
to_status: newStatus,
})
await refresh()
emit('updated')
} catch (err: any) {
Expand Down Expand Up @@ -241,6 +247,7 @@ function closePreview() {

async function handleDownload(docId: string) {
try {
track('document_downloaded', { document_id: docId })
await downloadDocument(docId)
Comment on lines 248 to 251
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

document_downloaded is emitted before confirming success.

At Line 250, failures still produce a “downloaded” event. Emit after await downloadDocument(docId) or rename to document_download_requested.

Suggested fix
 async function handleDownload(docId: string) {
   try {
-    track('document_downloaded', { document_id: docId })
     await downloadDocument(docId)
+    track('document_downloaded', { document_id: docId })
   } catch {
     toast.error('Failed to download document')
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function handleDownload(docId: string) {
try {
track('document_downloaded', { document_id: docId })
await downloadDocument(docId)
async function handleDownload(docId: string) {
try {
await downloadDocument(docId)
track('document_downloaded', { document_id: docId })
} catch {
toast.error('Failed to download document')
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/CandidateDetailSidebar.vue` around lines 248 - 251, The event
'document_downloaded' is emitted in handleDownload before confirming the
download succeeded; move the track('document_downloaded', { document_id: docId
}) call to after the await downloadDocument(docId) so it only fires on success,
or alternatively rename the event to 'document_download_requested' if you intend
to signal initiation rather than completion; update the track call in the
handleDownload function accordingly.

} catch {
toast.error('Failed to download document')
Expand Down
8 changes: 4 additions & 4 deletions app/components/PipelineCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ const transitionLabels: Record<string, string> = {

const transitionClasses: Record<string, string> = {
new: 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700',
screening: 'text-info-600 dark:text-info-400 hover:bg-info-50 dark:hover:bg-info-950',
interview: 'text-warning-600 dark:text-warning-400 hover:bg-warning-50 dark:hover:bg-warning-950',
offer: 'text-success-600 dark:text-success-400 hover:bg-success-50 dark:hover:bg-success-950',
hired: 'text-success-700 dark:text-success-300 hover:bg-success-100 dark:hover:bg-success-900',
screening: 'text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-950',
interview: 'text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-950',
offer: 'text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-950',
hired: 'text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900',
rejected: 'text-danger-600 dark:text-danger-400 hover:bg-danger-50 dark:hover:bg-danger-950',
}
</script>
Expand Down
2 changes: 2 additions & 0 deletions app/components/ScoreBreakdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const emit = defineEmits<{
(e: 'scored'): void
}>()

const { track } = useTrack()
const isAnalyzing = ref(false)
const analyzeError = ref<string | null>(null)
const expandedCriterion = ref<string | null>(null)
Expand Down Expand Up @@ -71,6 +72,7 @@ async function runAnalysis() {
headers: useRequestHeaders(['cookie']),
})
await refresh()
track('ai_analysis_run', { application_id: props.applicationId })
emit('scored')
} catch (err: any) {
analyzeError.value = err?.data?.statusMessage ?? 'Analysis failed. Make sure AI is configured in settings.'
Expand Down
26 changes: 26 additions & 0 deletions app/components/TimelineDateLink.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { History } from 'lucide-vue-next'

const props = defineProps<{
date: string | Date
}>()

const localePath = useLocalePath()

const timelineUrl = computed(() => {
const d = typeof props.date === 'string' ? props.date : props.date.toISOString()
const dateKey = d.slice(0, 10)
return localePath(`/dashboard/timeline?date=${dateKey}`)
})
</script>

<template>
<NuxtLink
:to="timelineUrl"
class="group/tl inline-flex items-center gap-1 transition-colors hover:text-brand-600 dark:hover:text-brand-400"
title="View in timeline"
>
<slot />
<History class="size-3 opacity-0 group-hover/tl:opacity-60 transition-opacity shrink-0" />
</NuxtLink>
</template>
1 change: 1 addition & 0 deletions app/composables/useJobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function useJobs(options?: {
description?: string
location?: string
type?: 'full_time' | 'part_time' | 'contract' | 'internship'
remoteStatus?: 'remote' | 'hybrid' | 'onsite'
requireResume?: boolean
requireCoverLetter?: boolean
autoScoreOnApply?: boolean
Expand Down
16 changes: 13 additions & 3 deletions app/composables/usePostHogIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,23 @@ export async function usePostHogIdentity() {
// Watch org AND consent for group analytics — same gating logic as above.
watch(
[() => activeOrgState.value?.data, hasConsented] as const,
([org, consented]) => {
async ([org, consented]) => {
if (consented) {
if (org?.id) {
// Only org id and name are forwarded; slug is omitted to minimise data.
;($posthogSetOrganization as (org: { id: string, name?: string }) => void)({
// Fetch the current member's role to enrich group properties — useful
// for debugging permission issues without exposing personal data.
let memberRole: string | undefined
try {
const { data } = await authClient.organization.getActiveMemberRole()
memberRole = data?.role ?? undefined
}
catch { /* non-critical; role is just an enrichment property */ }

// Only org id, name, and member role are forwarded; slug is omitted to minimise data.
;($posthogSetOrganization as (org: { id: string, name?: string, member_role?: string }) => void)({
id: org.id,
name: org.name || undefined,
member_role: memberRole,
})
Comment on lines +51 to 68
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent stale organization attribution from out-of-order async watcher runs.

At Line 58, the awaited role fetch can resolve after org/consent changes, and Line 64 can then send a stale org payload to PostHog. This can misattribute events to the wrong organization.

Suggested fix
   watch(
     [() => activeOrgState.value?.data, hasConsented] as const,
-    async ([org, consented]) => {
+    async ([org, consented], _prev, onCleanup) => {
+      let cancelled = false
+      onCleanup(() => { cancelled = true })
+
       if (consented) {
         if (org?.id) {
+          const orgId = org.id
           // Fetch the current member's role to enrich group properties — useful
           // for debugging permission issues without exposing personal data.
           let memberRole: string | undefined
           try {
             const { data } = await authClient.organization.getActiveMemberRole()
             memberRole = data?.role ?? undefined
           }
           catch { /* non-critical; role is just an enrichment property */ }

+          if (cancelled) return
+          if (!hasConsented.value) return
+          if (activeOrgState.value?.data?.id !== orgId) return
+
           // Only org id, name, and member role are forwarded; slug is omitted to minimise data.
           ;($posthogSetOrganization as (org: { id: string, name?: string, member_role?: string }) => void)({
-            id: org.id,
+            id: orgId,
             name: org.name || undefined,
             member_role: memberRole,
           })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async ([org, consented]) => {
if (consented) {
if (org?.id) {
// Only org id and name are forwarded; slug is omitted to minimise data.
;($posthogSetOrganization as (org: { id: string, name?: string }) => void)({
// Fetch the current member's role to enrich group properties — useful
// for debugging permission issues without exposing personal data.
let memberRole: string | undefined
try {
const { data } = await authClient.organization.getActiveMemberRole()
memberRole = data?.role ?? undefined
}
catch { /* non-critical; role is just an enrichment property */ }
// Only org id, name, and member role are forwarded; slug is omitted to minimise data.
;($posthogSetOrganization as (org: { id: string, name?: string, member_role?: string }) => void)({
id: org.id,
name: org.name || undefined,
member_role: memberRole,
})
async ([org, consented], _prev, onCleanup) => {
let cancelled = false
onCleanup(() => { cancelled = true })
if (consented) {
if (org?.id) {
const orgId = org.id
// Fetch the current member's role to enrich group properties — useful
// for debugging permission issues without exposing personal data.
let memberRole: string | undefined
try {
const { data } = await authClient.organization.getActiveMemberRole()
memberRole = data?.role ?? undefined
}
catch { /* non-critical; role is just an enrichment property */ }
if (cancelled) return
if (!hasConsented.value) return
if (activeOrgState.value?.data?.id !== orgId) return
// Only org id, name, and member role are forwarded; slug is omitted to minimise data.
;($posthogSetOrganization as (org: { id: string, name?: string, member_role?: string }) => void)({
id: orgId,
name: org.name || undefined,
member_role: memberRole,
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/composables/usePostHogIdentity.ts` around lines 51 - 68, The watcher
async callback may call authClient.organization.getActiveMemberRole() which can
resolve after org/consented have changed, causing a stale org to be sent to
PostHog; fix by snapshotting the relevant identity (e.g., const snapshotOrgId =
org?.id and const snapshotConsented = consented) before the await, then after
the await verify snapshotConsented is still true and org?.id === snapshotOrgId
(and org is defined) before calling $posthogSetOrganization; use the existing
symbols (the async watcher callback,
authClient.organization.getActiveMemberRole, and $posthogSetOrganization) to
implement this guard so out-of-order resolutions don’t send stale organization
data.

}
else {
Expand Down
Loading
Loading