Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
201 changes: 201 additions & 0 deletions app/composables/useTimeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* Composable for the Timeline page — fetches and manages paginated
* activity-log entries with cursor-based infinite scroll.
*/

export interface TimelineItem {
id: string
action: string
resourceType: string
resourceId: string
metadata: Record<string, unknown> | null
createdAt: string
actorId: string
actorName: string | null
actorEmail: string | null
actorImage: string | null
resourceName: string | null
resourceUrl: string | null
isUpcoming?: boolean
candidateId?: string
jobId?: string
}

export interface TimelineDayGroup {
date: string
label: string
isToday: boolean
isFuture: boolean
items: TimelineItem[]
}

export function useTimeline() {
const items = ref<TimelineItem[]>([])
const upcoming = ref<TimelineItem[]>([])
const isLoading = ref(false)
const isLoadingMore = ref(false)
const hasMore = ref(true)
const oldestTimestamp = ref<string | null>(null)
const error = ref<string | null>(null)
const activeFilter = ref<string | undefined>(undefined)

/**
* Load initial timeline data.
*/
async function loadInitial(resourceType?: string) {
isLoading.value = true
error.value = null
activeFilter.value = resourceType

try {
const query: Record<string, string | number> = { limit: 100 }
if (resourceType) query.resourceType = resourceType

const result = await $fetch('/api/activity-log/timeline', { query }) as {

Check failure on line 54 in app/composables/useTimeline.ts

View workflow job for this annotation

GitHub Actions / Build, typecheck, and test

Excessive stack depth comparing types 'Exclude<{ key: "/__nuxt_error"; exact: false; score: `${R}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWithoutQuery}?${string}` ? RouteSegWithoutQuery extends "" ? `${RouteRest}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegW...' and '{ score: MaxTuple<((R extends "/api/activity-log" ? { key: "/api/activity-log"; exact: true; score: []; catchAll: false; } : { key: "/api/activity-log"; exact: false; score: `${R}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWithoutQuery}?${string}` ? RouteSegWithoutQuer...'.

Check failure on line 54 in app/composables/useTimeline.ts

View workflow job for this annotation

GitHub Actions / Build, typecheck, and test

Excessive stack depth comparing types 'Exclude<R extends "/api/activity-log" ? { key: "/api/activity-log"; exact: true; score: []; catchAll: false; } : { key: "/api/activity-log"; exact: false; score: `${R}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWithoutQuery}?${string}` ? RouteSegWithoutQuery extends ""...' and '{ score: MaxTuple<((R extends "/api/activity-log" ? { key: "/api/activity-log"; exact: true; score: []; catchAll: false; } : { key: "/api/activity-log"; exact: false; score: `${R}/` extends `${infer RouteSeg}/${infer RouteRest}` ? `${RouteSeg}?` extends `${infer RouteSegWithoutQuery}?${string}` ? RouteSegWithoutQuer...'.
items: TimelineItem[]
upcoming: TimelineItem[]
hasMore: boolean
oldestTimestamp: string | null
newestTimestamp: string | null
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

items.value = result.items
upcoming.value = result.upcoming
hasMore.value = result.hasMore
oldestTimestamp.value = result.oldestTimestamp
Comment on lines +73 to +87
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 responses from crossing filter boundaries.

loadInitial() and loadMore() both commit into the same refs after await, but loadMore() is still allowed while an initial reload is in flight. A late response from the previous filter can overwrite the fresh list or append an old page into the new filter's results.

Suggested fix
 export function useTimeline() {
   const items = ref<TimelineItem[]>([])
   const upcoming = ref<TimelineItem[]>([])
   const isLoading = ref(false)
   const isLoadingMore = ref(false)
   const hasMore = ref(true)
   const oldestTimestamp = ref<string | null>(null)
   const error = ref<string | null>(null)
   const activeFilter = ref<string | undefined>(undefined)
+  let requestVersion = 0

   async function loadInitial(resourceType?: string) {
+    const version = ++requestVersion
     isLoading.value = true
     error.value = null
-    activeFilter.value = resourceType

     try {
       const query: Record<string, string | number> = { limit: 100 }
       if (resourceType) query.resourceType = resourceType

       const result = await $fetch('/api/activity-log/timeline', { query }) as {
         items: TimelineItem[]
         upcoming: TimelineItem[]
         hasMore: boolean
         oldestTimestamp: string | null
         newestTimestamp: string | null
       }

+      if (version !== requestVersion) return
+      activeFilter.value = resourceType
       items.value = result.items
       upcoming.value = result.upcoming
       hasMore.value = result.hasMore
       oldestTimestamp.value = result.oldestTimestamp
     }
@@
   async function loadMore() {
-    if (isLoadingMore.value || !hasMore.value || !oldestTimestamp.value) return
+    if (isLoading.value || isLoadingMore.value || !hasMore.value || !oldestTimestamp.value) return
+    const version = requestVersion

     isLoadingMore.value = true

     try {
@@
       const result = await $fetch('/api/activity-log/timeline', { query }) as {
         items: TimelineItem[]
         upcoming: TimelineItem[]
         hasMore: boolean
         oldestTimestamp: string | null
         newestTimestamp: string | null
       }

+      if (version !== requestVersion) return
       items.value.push(...result.items)
       hasMore.value = result.hasMore
       oldestTimestamp.value = result.oldestTimestamp

Also applies to: 106-128

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/composables/useTimeline.ts` around lines 72 - 92, loadInitial and
loadMore can apply late responses from a previous filter run into the current
refs (items, upcoming, hasMore, etc.); fix by snapshotting a request token or
the current activeFilter before the async call (e.g., const requestFilter =
activeFilter.value or increment a loadCounter) and after the await return early
unless the token still matches (or the counter equals current). Apply the same
guard in both loadInitial and loadMore so that any response that does not match
the activeFilter/request token is ignored and does not mutate items, upcoming,
hasMore, oldestTimestamp or newestTimestamp.

}
catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load timeline'
console.error('[Timeline] Failed to load:', err)
}
finally {
isLoading.value = false
}
}

/**
* Load more (older) entries for infinite scroll.
*/
async function loadMore() {
if (isLoadingMore.value || !hasMore.value || !oldestTimestamp.value) return

isLoadingMore.value = true

try {
const query: Record<string, string | number> = {
before: oldestTimestamp.value,
limit: 100,
}
if (activeFilter.value) query.resourceType = activeFilter.value

const result = await $fetch('/api/activity-log/timeline', { query }) as {
items: TimelineItem[]
upcoming: TimelineItem[]
hasMore: boolean
oldestTimestamp: string | null
newestTimestamp: string | null
}

items.value.push(...result.items)
hasMore.value = result.hasMore
oldestTimestamp.value = result.oldestTimestamp
}
catch (err) {
console.error('[Timeline] Failed to load more:', err)
}
Comment on lines +119 to +121
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

Inconsistent error handling: loadMore doesn't expose errors to consumers.

loadInitial sets error.value on failure (line 90), but loadMore only logs to console. Consumers relying on the error ref won't know when pagination fails, potentially leaving users stuck with no feedback.

Suggested fix
     catch (err) {
+      error.value = err instanceof Error ? err.message : 'Failed to load more items'
       console.error('[Timeline] Failed to load more:', err)
     }
📝 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
catch (err) {
console.error('[Timeline] Failed to load more:', err)
}
catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load more items'
console.error('[Timeline] Failed to load more:', err)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/composables/useTimeline.ts` around lines 119 - 121, The catch block in
loadMore currently only logs to console and doesn't update the shared error
state like loadInitial does; update the loadMore error handling so that on
failure you set error.value to the caught error (or a formatted message), ensure
isLoading.value is set to false if applicable, and keep any existing state
updates consistent with loadInitial so consumers reading the error ref see the
pagination failure (refer to the loadMore function and the error.value used in
loadInitial).

finally {
isLoadingMore.value = false
}
}

/**
* Group timeline items by day, including upcoming events.
*/
const dayGroups = computed<TimelineDayGroup[]>(() => {
const now = new Date()
const todayStr = formatDateKey(now)

// Combine upcoming + past items
const allItems = [...upcoming.value, ...items.value]

// Group by date
const groupMap = new Map<string, TimelineItem[]>()
for (const item of allItems) {
const dateKey = item.createdAt.slice(0, 10)
if (!groupMap.has(dateKey)) {
Comment on lines +139 to +141
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

🧩 Analysis chain

🏁 Script executed:

cat -n app/composables/useTimeline.ts | head -150

Repository: reqcore-inc/reqcore

Length of output: 5321


🏁 Script executed:

cat -n app/composables/useTimeline.ts | tail -100

Repository: reqcore-inc/reqcore

Length of output: 3561


Group timeline items by local date, not UTC prefix.

Line 124 uses item.createdAt.slice(0, 10) which extracts the date portion from an ISO timestamp (UTC-based), while line 116 uses formatDateKey(now) which generates a date key in local time. This timezone mismatch causes items to be grouped under the wrong day header near midnight, and the Today/Tomorrow labels (lines 189-191) will be incorrect for affected events.

Suggested fix
-      const dateKey = item.createdAt.slice(0, 10)
+      const dateKey = formatDateKey(new Date(item.createdAt))
📝 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
for (const item of allItems) {
const dateKey = item.createdAt.slice(0, 10)
if (!groupMap.has(dateKey)) {
for (const item of allItems) {
const dateKey = formatDateKey(new Date(item.createdAt))
if (!groupMap.has(dateKey)) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/composables/useTimeline.ts` around lines 123 - 125, The code is grouping
items by the UTC date string from item.createdAt (item.createdAt.slice(0, 10)),
which mismatches the local-date key produced by formatDateKey(now); instead,
parse item.createdAt into a Date and convert it to the same local date key
format used by formatDateKey so grouping uses local time; update the loop in
useTimeline (where allItems and groupMap are used) to compute dateKey by
creating a Date from item.createdAt and passing/formatting that Date with the
same logic as formatDateKey (or call formatDateKey with that Date) so
Today/Tomorrow labels and grouping are consistent.

groupMap.set(dateKey, [])
}
groupMap.get(dateKey)!.push(item)
}

// Sort dates descending (newest → oldest) but future dates first
const sortedDates = Array.from(groupMap.keys()).sort((a, b) => {
const aFuture = a > todayStr
const bFuture = b > todayStr

// Future dates at top, sorted ascending (soonest first)
if (aFuture && bFuture) return a.localeCompare(b)
if (aFuture) return -1
if (bFuture) return 1

// Past dates sorted descending (most recent first)
return b.localeCompare(a)
})

return sortedDates.map(date => ({
date,
label: formatDayLabel(date, todayStr),
isToday: date === todayStr,
isFuture: date > todayStr,
items: groupMap.get(date)!.sort((a, b) => {
// Within each day, sort by time descending
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
}),
}))
})

/**
* Get total event count.
*/
const totalEvents = computed(() => items.value.length + upcoming.value.length)

return {
items,
upcoming,
dayGroups,
totalEvents,
isLoading,
isLoadingMore,
hasMore,
error,
activeFilter,
loadInitial,
loadMore,
}
}

function formatDateKey(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}

function formatDayLabel(dateStr: string, todayStr: string): string {
const date = new Date(dateStr + 'T00:00:00')
const today = new Date(todayStr + 'T00:00:00')
const diffDays = Math.round((date.getTime() - today.getTime()) / 86400000)

if (diffDays === 0) return 'Today'
if (diffDays === 1) return 'Tomorrow'
if (diffDays === -1) return 'Yesterday'
if (diffDays > 1 && diffDays <= 7) return `In ${diffDays} days`
if (diffDays < -1 && diffDays >= -7) return `${Math.abs(diffDays)} days ago`

return date.toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: date.getFullYear() !== today.getFullYear() ? 'numeric' : undefined,
})
}
Loading
Loading