Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions alembic/versions/0012_reconcile_papers_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ def upgrade() -> None:
_add_column_if_missing(
"papers", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True)
)
_add_column_if_missing(
"papers", sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=True)
)

# Backfill first_seen_at from created_at if missing.
if "first_seen_at" in _columns("papers") and "created_at" in _columns("papers"):
op.execute(
sa.text("UPDATE papers SET first_seen_at = created_at WHERE first_seen_at IS NULL")
)

# Fill empty title_hash so ORM non-null assumptions hold.
if "title_hash" in _columns("papers"):
Expand Down
14 changes: 8 additions & 6 deletions web/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { LLMUsageChart } from "@/components/dashboard/LLMUsageChart"
import { PipelineStatus } from "@/components/dashboard/PipelineStatus"
import { ReadingQueue } from "@/components/dashboard/ReadingQueue"
import { ScholarSignalsPanel } from "@/components/dashboard/ScholarSignalsPanel"
import { TrackSpotlight } from "@/components/dashboard/TrackSpotlight"
import { TrackSpotlightSection } from "@/components/dashboard/TrackSpotlightSection"
import {
fetchDeadlineRadar,
fetchLLMUsage,
Expand Down Expand Up @@ -169,11 +169,13 @@ export default async function DashboardPage() {
tokenCalls={usageSummary.totals.calls}
/>

<TrackSpotlight
track={activeTrack}
feedItems={feedItems}
feedTotal={feedTotal}
anchors={anchors}
<TrackSpotlightSection
initialTracks={tracks}
initialActiveTrack={activeTrack}
initialFeedItems={feedItems}
initialFeedTotal={feedTotal}
initialAnchors={anchors}
userId="default"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The userId is hardcoded as "default". While this might be acceptable for initial development or a single-user context, it will need to be replaced with a dynamic value from an authentication context or session when multi-user support is implemented. This hardcoded value appears in several places in this PR.

/>

<Card>
Expand Down
73 changes: 55 additions & 18 deletions web/src/components/dashboard/TrackSpotlight.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
"use client"

import Link from "next/link"
import { ArrowRight, Bookmark, Compass, Hash, Sparkles, Star } from "lucide-react"
import { Bookmark, Check, ChevronDown, Compass, Hash, Loader2, Sparkles, Star } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import type { AnchorPreviewItem, ResearchTrackSummary, TrackFeedItem } from "@/lib/types"

interface TrackSpotlightProps {
track: ResearchTrackSummary | null
tracks: ResearchTrackSummary[]
activeTrack: ResearchTrackSummary | null
onSelectTrack: (trackId: number) => void
feedItems: TrackFeedItem[]
feedTotal: number
anchors: AnchorPreviewItem[]
isLoading?: boolean
}

function toPaperHref(item: TrackFeedItem): string {
Expand All @@ -22,8 +33,16 @@ function formatScore(score?: number): string {
return score.toFixed(1)
}

export function TrackSpotlight({ track, feedItems, feedTotal, anchors }: TrackSpotlightProps) {
if (!track) {
export function TrackSpotlight({
tracks,
activeTrack,
onSelectTrack,
feedItems,
feedTotal,
anchors,
isLoading = false,
}: TrackSpotlightProps) {
if (!activeTrack) {
return (
<Card className="border-dashed">
<CardHeader>
Expand All @@ -41,7 +60,7 @@ export function TrackSpotlight({ track, feedItems, feedTotal, anchors }: TrackSp
)
}

const keywords = (track.keywords || []).slice(0, 6)
const keywords = (activeTrack.keywords || []).slice(0, 6)

return (
<Card>
Expand All @@ -50,17 +69,42 @@ export function TrackSpotlight({ track, feedItems, feedTotal, anchors }: TrackSp
<div>
<CardTitle className="text-base flex items-center gap-2">
<Compass className="h-4 w-4 text-primary" />
Track Spotlight · {track.name}
Track Spotlight · {activeTrack.name}
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{track.description || "Track-level feed blending keyword match, feedback preference and judge score."}
{activeTrack.description || "Track-level feed blending keyword match, feedback preference and judge score."}
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link href={`/research?track_id=${track.id}`}>Open Track</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={isLoading}>
Select Tracks
<ChevronDown className="ml-1 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{tracks.map((track) => (
<DropdownMenuItem
key={track.id}
onClick={() => onSelectTrack(track.id)}
className="flex items-center gap-2"
>
{track.id === activeTrack.id ? (
<Check className="h-4 w-4" />
) : (
<span className="w-4" />
)}
<span className="truncate">{track.name}</span>
</DropdownMenuItem>
))}
{tracks.length === 0 && (
<DropdownMenuItem disabled>No tracks available</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{!!keywords.length && (
{keywords.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{keywords.map((keyword) => (
<Badge key={keyword} variant="secondary" className="text-xs">
Expand Down Expand Up @@ -108,13 +152,6 @@ export function TrackSpotlight({ track, feedItems, feedTotal, anchors }: TrackSp
))}
</div>
)}

<Button asChild variant="ghost" size="sm" className="px-0">
<Link href={`/research?track_id=${track.id}`}>
View full feed
<ArrowRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
</div>

<div className="space-y-2 xl:col-span-2">
Expand Down
92 changes: 92 additions & 0 deletions web/src/components/dashboard/TrackSpotlightSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"use client"

import { useCallback, useState } from "react"

import { TrackSpotlight } from "./TrackSpotlight"
import type { AnchorPreviewItem, ResearchTrackSummary, TrackFeedItem } from "@/lib/types"

interface TrackSpotlightSectionProps {
initialTracks: ResearchTrackSummary[]
initialActiveTrack: ResearchTrackSummary | null
initialFeedItems: TrackFeedItem[]
initialFeedTotal: number
initialAnchors: AnchorPreviewItem[]
userId?: string
}

export function TrackSpotlightSection({
initialTracks,
initialActiveTrack,
initialFeedItems,
initialFeedTotal,
initialAnchors,
userId = "default",
}: TrackSpotlightSectionProps) {
const [tracks] = useState(initialTracks)
const [activeTrack, setActiveTrack] = useState(initialActiveTrack)
const [feedItems, setFeedItems] = useState(initialFeedItems)
const [feedTotal, setFeedTotal] = useState(initialFeedTotal)
const [anchors, setAnchors] = useState(initialAnchors)
const [isLoading, setIsLoading] = useState(false)

const handleSelectTrack = useCallback(
async (trackId: number) => {
const selectedTrack = tracks.find((t) => t.id === trackId)
if (!selectedTrack || selectedTrack.id === activeTrack?.id) return

setIsLoading(true)
setActiveTrack(selectedTrack)

try {
// Fetch feed and anchors for the new track
const [feedRes, anchorsRes] = await Promise.all([
fetch(`/api/research/tracks/${trackId}/feed?user_id=${encodeURIComponent(userId)}&limit=6`),
fetch(`/api/research/tracks/${trackId}/anchors/discover?user_id=${encodeURIComponent(userId)}&limit=4`),
])
Comment on lines +42 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The fetch calls to the API are missing the cache: "no-store" option. This can lead to the browser serving stale data from its cache, which is undesirable for a dynamic dashboard. Other parts of the application, like in web/src/lib/api.ts, consistently use this option to ensure data freshness. I recommend adding it to all fetch calls in this component, including the one to activate the track.

Suggested change
const [feedRes, anchorsRes] = await Promise.all([
fetch(`/api/research/tracks/${trackId}/feed?user_id=${encodeURIComponent(userId)}&limit=6`),
fetch(`/api/research/tracks/${trackId}/anchors/discover?user_id=${encodeURIComponent(userId)}&limit=4`),
])
const [feedRes, anchorsRes] = await Promise.all([
fetch(`/api/research/tracks/${trackId}/feed?user_id=${encodeURIComponent(userId)}&limit=6`, { cache: "no-store" }),
fetch(`/api/research/tracks/${trackId}/anchors/discover?user_id=${encodeURIComponent(userId)}&limit=4`, { cache: "no-store" }),
])


if (feedRes.ok) {
const feedData = await feedRes.json()
setFeedItems(feedData.items || [])
setFeedTotal(feedData.total || 0)
} else {
setFeedItems([])
setFeedTotal(0)
}

if (anchorsRes.ok) {
const anchorsData = await anchorsRes.json()
setAnchors(anchorsData.items || [])
} else {
setAnchors([])
}

// Optionally activate the track on the backend
await fetch(`/api/research/tracks/${trackId}/activate?user_id=${encodeURIComponent(userId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
})
Comment on lines +63 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 | 🟡 Minor

Backend activation failure silently leaves stale activeTrack state.

If the activation POST fails, activeTrack has already been set to selectedTrack (line 38) and feed/anchors are populated. The failure is only logged, not surfaced to the user. More importantly, if the feed/anchor fetches succeed but activation fails, the user sees the correct data but the backend's "active" flag is out of sync — which could cause a different track to appear active on page reload.

Consider either moving the activation call into the Promise.all so its failure triggers the catch block, or reverting activeTrack on activation failure.

🤖 Prompt for AI Agents
In `@web/src/components/dashboard/TrackSpotlightSection.tsx` around lines 63 - 68,
The activation POST is executed after setting activeTrack (selectedTrack) so a
failed activation can leave the client state inconsistent; modify the logic in
TrackSpotlightSection (where activeTrack and selectedTrack are used) to either
include the activation fetch call in the same Promise.all that fetches
feed/anchors so any failure goes to the existing catch, or if you prefer to keep
it separate, catch errors from the activation fetch and revert activeTrack back
to the previous value (or clear it) and surface an error to the user; locate the
activation call to `/api/research/tracks/${trackId}/activate` and the state
setter activeTrack (and any place selectedTrack is assigned) and update
accordingly.

} catch (error) {
console.error("Failed to fetch track data:", error)
setFeedItems([])
setFeedTotal(0)
setAnchors([])
} finally {
setIsLoading(false)
}
},
[tracks, activeTrack?.id, userId]
)

return (
<TrackSpotlight
tracks={tracks}
activeTrack={activeTrack}
onSelectTrack={handleSelectTrack}
feedItems={feedItems}
feedTotal={feedTotal}
anchors={anchors}
isLoading={isLoading}
/>
)
}
Loading
Loading