feat: resolve observer project page round default and add selector

The observer full project page used to call getProjectDetail without
a round, getting cross-round contaminated stats. It now resolves a
default — the currently OPEN round the project is in, falling back
to the most recently CLOSED one — and renders a selector chip in
the score card whenever the project participated in more than one
candidate round. Initial selection respects the ?round= query param.

A new observer procedure (getProjectRoundsForObserver) returns the
project's open or closed rounds for the picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-27 13:18:36 +02:00
parent cfd9dc6afe
commit 6f3e8885e0
3 changed files with 67 additions and 4 deletions

View File

@@ -6,10 +6,13 @@ export const dynamic = 'force-dynamic'
export default async function ObserverProjectDetailPage({ export default async function ObserverProjectDetailPage({
params, params,
searchParams,
}: { }: {
params: Promise<{ projectId: string }> params: Promise<{ projectId: string }>
searchParams: Promise<{ round?: string }>
}) { }) {
const { projectId } = await params const { projectId } = await params
const sp = await searchParams
return <ObserverProjectDetail projectId={projectId} /> return <ObserverProjectDetail projectId={projectId} initialRoundId={sp.round} />
} }

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
@@ -44,15 +45,41 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { cn, formatDate, formatDateOnly } from '@/lib/utils' import { cn, formatDate, formatDateOnly } from '@/lib/utils'
export function ObserverProjectDetail({ projectId }: { projectId: string }) { export function ObserverProjectDetail({
projectId,
initialRoundId,
}: {
projectId: string
initialRoundId?: string
}) {
const router = useRouter() const router = useRouter()
const [activeRoundId, setActiveRoundId] = useState<string | undefined>(initialRoundId)
// Resolve a default round when none is set: prefer the currently OPEN round
// the project participates in, fall back to the most recently CLOSED one.
const { data: roundCandidates } = trpc.analytics.getProjectRoundsForObserver.useQuery(
{ projectId },
)
useEffect(() => {
if (activeRoundId || !roundCandidates) return
const active = roundCandidates.find((r) => r.status === 'ROUND_ACTIVE')
if (active) {
setActiveRoundId(active.id)
return
}
const closed = [...roundCandidates]
.filter((r) => r.status === 'ROUND_CLOSED')
.sort((a, b) => b.sortOrder - a.sortOrder)[0]
if (closed) setActiveRoundId(closed.id)
}, [roundCandidates, activeRoundId])
const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery( const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery(
{ id: projectId }, { id: projectId, roundId: activeRoundId },
{ refetchInterval: 30_000 }, { refetchInterval: 30_000 },
) )
const { data: flags } = trpc.settings.getFeatureFlags.useQuery() const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
const roundId = data?.assignments?.[0]?.roundId as string | undefined const roundId = activeRoundId ?? (data?.assignments?.[0]?.roundId as string | undefined)
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery( const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId: roundId ?? '', category: data?.project?.competitionCategory }, { roundId: roundId ?? '', category: data?.project?.competitionCategory },
{ enabled: !!roundId }, { enabled: !!roundId },
@@ -223,6 +250,19 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
{stats.yesPercentage.toFixed(0)}% recommended {stats.yesPercentage.toFixed(0)}% recommended
</p> </p>
)} )}
{roundCandidates && roundCandidates.length > 1 && (
<div className="mt-3 w-full">
<select
className="w-full rounded border bg-background px-2 py-1 text-xs"
value={activeRoundId ?? ''}
onChange={(e) => setActiveRoundId(e.target.value)}
>
{roundCandidates.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -2187,6 +2187,26 @@ export const analyticsRouter = router({
} }
}), }),
/**
* Returns rounds the project has participated in, restricted to those that
* are open or already closed. Used by the observer full project page to
* resolve a default round when none is specified in the URL.
*/
getProjectRoundsForObserver: observerProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const states = await ctx.prisma.projectRoundState.findMany({
where: { projectId: input.projectId },
select: {
round: { select: { id: true, name: true, status: true, sortOrder: true } },
},
})
return states
.map((s) => s.round)
.filter((r) => r.status === 'ROUND_ACTIVE' || r.status === 'ROUND_CLOSED')
.sort((a, b) => a.sortOrder - b.sortOrder)
}),
getRecentFiles: observerProcedure getRecentFiles: observerProcedure
.input(z.object({ roundId: z.string(), limit: z.number().min(1).max(50).default(10) })) .input(z.object({ roundId: z.string(), limit: z.number().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {