feat: finalization tab respects ranking overrides, grouped by category
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m2s

- processRoundClose now applies reordersJson drag-reorder overrides
  when building the evaluation pass set (was ignoring admin reorders)
- Finalization tab groups proposed outcomes by category (Startup/Concept)
  with per-group pass/reject/total counts
- Added category filter dropdown alongside the existing outcome filter
- Removed legacy "Advance Top N" button and dialog from ranking page
  (replaced by the finalization workflow)
- Fix project edit status defaultValue showing empty placeholder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 22:10:04 +01:00
parent 43801340f8
commit 050836d522
4 changed files with 182 additions and 363 deletions

View File

@@ -186,7 +186,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
title: '',
teamName: '',
description: '',
status: undefined,
status: 'SUBMITTED' as const,
tags: [],
competitionCategory: '',
oceanIssue: '',

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useMemo } from 'react'
import React, { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@@ -76,6 +76,7 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
const [search, setSearch] = useState('')
const [filterOutcome, setFilterOutcome] = useState<'all' | 'PASSED' | 'REJECTED' | 'none'>('all')
const [filterCategory, setFilterCategory] = useState<'all' | 'STARTUP' | 'BUSINESS_CONCEPT'>('all')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [emailSectionOpen, setEmailSectionOpen] = useState(false)
const [advancementMessage, setAdvancementMessage] = useState('')
@@ -135,9 +136,32 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
(filterOutcome === 'none' && !p.proposedOutcome) ||
p.proposedOutcome === filterOutcome
return matchesSearch && matchesFilter
const matchesCategory =
filterCategory === 'all' || p.category === filterCategory
return matchesSearch && matchesFilter && matchesCategory
})
}, [summary, search, filterOutcome])
}, [summary, search, filterOutcome, filterCategory])
// Check if we have multiple categories (to decide whether to group)
const hasMultipleCategories = useMemo(() => {
if (!summary) return false
const cats = new Set(summary.projects.map((p) => p.category).filter(Boolean))
return cats.size > 1
}, [summary])
// Group filtered projects by category, sorted by rank within each group
const groupedProjects = useMemo(() => {
if (!hasMultipleCategories || filterCategory !== 'all') return null
const groups: { category: string; label: string; projects: typeof filteredProjects }[] = []
const startups = filteredProjects.filter((p) => p.category === 'STARTUP')
const concepts = filteredProjects.filter((p) => p.category === 'BUSINESS_CONCEPT')
const other = filteredProjects.filter((p) => p.category !== 'STARTUP' && p.category !== 'BUSINESS_CONCEPT')
if (startups.length > 0) groups.push({ category: 'STARTUP', label: 'Startups', projects: startups })
if (concepts.length > 0) groups.push({ category: 'BUSINESS_CONCEPT', label: 'Business Concepts', projects: concepts })
if (other.length > 0) groups.push({ category: 'OTHER', label: 'Other', projects: other })
return groups
}, [filteredProjects, hasMultipleCategories, filterCategory])
// Counts
const passedCount = summary?.projects.filter((p) => p.proposedOutcome === 'PASSED').length ?? 0
@@ -163,6 +187,93 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
batchUpdate.mutate({ roundId, outcomes })
}
// Column count for colSpan
const colCount = (summary?.isFinalized ? 0 : 1) + 4 + (summary?.roundType === 'EVALUATION' ? 1 : 0) + 1
// Shared row renderer
const renderProjectRow = (project: (typeof filteredProjects)[number]) => (
<tr key={project.id} className="border-b last:border-0 hover:bg-muted/30">
{!summary?.isFinalized && (
<td className="px-3 py-2.5">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={(checked) => {
const next = new Set(selectedIds)
if (checked) next.add(project.id)
else next.delete(project.id)
setSelectedIds(next)
}}
aria-label={`Select ${project.title}`}
/>
</td>
)}
<td className="px-3 py-2.5">
<div className="font-medium truncate max-w-[200px]">{project.title}</div>
{project.teamName && (
<div className="text-xs text-muted-foreground truncate">{project.teamName}</div>
)}
</td>
<td className="px-3 py-2.5 hidden sm:table-cell text-muted-foreground">
{project.category === 'STARTUP' ? 'Startup' : project.category === 'BUSINESS_CONCEPT' ? 'Concept' : project.category ?? '-'}
</td>
<td className="px-3 py-2.5 hidden md:table-cell text-muted-foreground">
{project.country ?? '-'}
</td>
<td className="px-3 py-2.5 text-center">
<Badge variant="secondary" className={cn('text-xs', stateLabelColors[project.currentState] ?? '')}>
{project.currentState.replace('_', ' ')}
</Badge>
</td>
{summary?.roundType === 'EVALUATION' && (
<td className="px-3 py-2.5 text-center hidden lg:table-cell text-muted-foreground">
{project.evaluationScore != null
? `${project.evaluationScore.toFixed(1)} (#${project.rankPosition ?? '-'})`
: '-'}
</td>
)}
<td className="px-3 py-2.5 text-center">
{summary?.isFinalized ? (
<Badge
variant="secondary"
className={cn(
'text-xs',
project.proposedOutcome === 'PASSED' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700',
)}
>
{project.proposedOutcome === 'PASSED' ? 'Advanced' : 'Rejected'}
</Badge>
) : (
<Select
value={project.proposedOutcome ?? 'undecided'}
onValueChange={(v) => {
if (v === 'undecided') return
updateOutcome.mutate({
roundId,
projectId: project.id,
proposedOutcome: v as 'PASSED' | 'REJECTED',
})
}}
>
<SelectTrigger
className={cn(
'h-8 w-[130px] text-xs mx-auto',
project.proposedOutcome === 'PASSED' && 'border-green-300 bg-green-50 text-green-700',
project.proposedOutcome === 'REJECTED' && 'border-red-300 bg-red-50 text-red-700',
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="undecided" disabled>Undecided</SelectItem>
<SelectItem value="PASSED">Pass</SelectItem>
<SelectItem value="REJECTED">Reject</SelectItem>
</SelectContent>
</Select>
)}
</td>
</tr>
)
if (isLoading) {
return (
<div className="space-y-4">
@@ -362,12 +473,24 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
<SelectValue placeholder="Filter by outcome" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
<SelectItem value="all">All outcomes</SelectItem>
<SelectItem value="PASSED">Proposed: Pass</SelectItem>
<SelectItem value="REJECTED">Proposed: Reject</SelectItem>
<SelectItem value="none">Undecided</SelectItem>
</SelectContent>
</Select>
{hasMultipleCategories && (
<Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as typeof filterCategory)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
<SelectItem value="STARTUP">Startups</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concepts</SelectItem>
</SelectContent>
</Select>
)}
</div>
{/* Bulk actions */}
@@ -422,95 +545,30 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
</tr>
</thead>
<tbody>
{filteredProjects.map((project) => (
<tr key={project.id} className="border-b last:border-0 hover:bg-muted/30">
{!summary.isFinalized && (
<td className="px-3 py-2.5">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={(checked) => {
const next = new Set(selectedIds)
if (checked) next.add(project.id)
else next.delete(project.id)
setSelectedIds(next)
}}
aria-label={`Select ${project.title}`}
/>
</td>
)}
<td className="px-3 py-2.5">
<div className="font-medium truncate max-w-[200px]">{project.title}</div>
{project.teamName && (
<div className="text-xs text-muted-foreground truncate">{project.teamName}</div>
)}
</td>
<td className="px-3 py-2.5 hidden sm:table-cell text-muted-foreground">
{project.category === 'STARTUP' ? 'Startup' : project.category === 'BUSINESS_CONCEPT' ? 'Concept' : project.category ?? '-'}
</td>
<td className="px-3 py-2.5 hidden md:table-cell text-muted-foreground">
{project.country ?? '-'}
</td>
<td className="px-3 py-2.5 text-center">
<Badge
variant="secondary"
className={cn('text-xs', stateLabelColors[project.currentState] ?? '')}
>
{project.currentState.replace('_', ' ')}
</Badge>
</td>
{summary.roundType === 'EVALUATION' && (
<td className="px-3 py-2.5 text-center hidden lg:table-cell text-muted-foreground">
{project.evaluationScore != null
? `${project.evaluationScore.toFixed(1)} (#${project.rankPosition ?? '-'})`
: '-'}
</td>
)}
<td className="px-3 py-2.5 text-center">
{summary.isFinalized ? (
<Badge
variant="secondary"
className={cn(
'text-xs',
project.proposedOutcome === 'PASSED' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700',
)}
{groupedProjects ? (
groupedProjects.map((group) => (
<React.Fragment key={group.category}>
<tr className="bg-muted/70">
<td
colSpan={colCount}
className="px-3 py-2 font-semibold text-sm tracking-wide uppercase text-muted-foreground"
>
{project.proposedOutcome === 'PASSED' ? 'Advanced' : 'Rejected'}
</Badge>
) : (
<Select
value={project.proposedOutcome ?? 'undecided'}
onValueChange={(v) => {
if (v === 'undecided') return
updateOutcome.mutate({
roundId,
projectId: project.id,
proposedOutcome: v as 'PASSED' | 'REJECTED',
})
}}
>
<SelectTrigger
className={cn(
'h-8 w-[130px] text-xs mx-auto',
project.proposedOutcome === 'PASSED' && 'border-green-300 bg-green-50 text-green-700',
project.proposedOutcome === 'REJECTED' && 'border-red-300 bg-red-50 text-red-700',
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="undecided" disabled>Undecided</SelectItem>
<SelectItem value="PASSED">Pass</SelectItem>
<SelectItem value="REJECTED">Reject</SelectItem>
</SelectContent>
</Select>
)}
</td>
</tr>
))}
{group.label}
<span className="ml-2 text-xs font-normal normal-case">
({group.projects.filter((p) => p.proposedOutcome === 'PASSED').length} pass, {group.projects.filter((p) => p.proposedOutcome === 'REJECTED').length} reject, {group.projects.length} total)
</span>
</td>
</tr>
{group.projects.map((project) => renderProjectRow(project))}
</React.Fragment>
))
) : (
filteredProjects.map((project) => renderProjectRow(project))
)}
{filteredProjects.length === 0 && (
<tr>
<td
colSpan={summary.isFinalized ? 6 : 7}
colSpan={colCount}
className="px-3 py-8 text-center text-muted-foreground"
>
No projects match your search/filter

View File

@@ -33,14 +33,6 @@ import {
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
@@ -57,7 +49,6 @@ import {
Loader2,
RefreshCw,
Sparkles,
Trophy,
ExternalLink,
ChevronDown,
Settings2,
@@ -251,14 +242,6 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
const initialized = useRef(false)
const pendingReorderCount = useRef(0)
// ─── Advance dialog state ─────────────────────────────────────────────────
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
const [advanceMode, setAdvanceMode] = useState<'top_n' | 'threshold'>('top_n')
const [topNStartup, setTopNStartup] = useState(3)
const [topNConceptual, setTopNConceptual] = useState(3)
const [scoreThreshold, setScoreThreshold] = useState(5)
const [includeReject, setIncludeReject] = useState(false)
// ─── Export state ──────────────────────────────────────────────────────────
const [exportLoading, setExportLoading] = useState(false)
@@ -349,28 +332,6 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
},
})
const advanceMutation = trpc.round.advanceProjects.useMutation({
onSuccess: (data) => {
toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`)
void utils.roundEngine.getProjectStates.invalidate({ roundId })
setAdvanceDialogOpen(false)
},
onError: (err) => toast.error(err.message),
})
const batchRejectMutation = trpc.roundEngine.batchTransition.useMutation({
onSuccess: (data) => {
// MEMORY.md: use .length, not direct value comparison
toast.success(`Rejected ${data.succeeded.length} project(s)`)
if (data.failed.length > 0) {
toast.warning(`${data.failed.length} project(s) could not be rejected`)
}
void utils.roundEngine.getProjectStates.invalidate({ roundId })
setAdvanceDialogOpen(false)
},
onError: (err) => toast.error(err.message),
})
// ─── evalConfig (advancement counts from round config) ────────────────────
const evalConfig = useMemo(() => {
if (!roundData?.configJson) return null
@@ -518,14 +479,6 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
// Derive ranking mode from criteria text
const isFormulaMode = !localCriteriaText.trim()
// ─── sync advance dialog defaults from config ────────────────────────────
useEffect(() => {
if (evalConfig) {
if (evalConfig.startupAdvanceCount > 0) setTopNStartup(evalConfig.startupAdvanceCount)
if (evalConfig.conceptAdvanceCount > 0) setTopNConceptual(evalConfig.conceptAdvanceCount)
}
}, [evalConfig])
// ─── handleDragEnd ────────────────────────────────────────────────────────
function handleDragEnd(category: 'STARTUP' | 'BUSINESS_CONCEPT', event: DragEndEvent) {
const { active, over } = event
@@ -546,50 +499,6 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
})
}
// ─── Compute threshold-based project IDs ──────────────────────────────────
const thresholdAdvanceIds = useMemo(() => {
if (advanceMode !== 'threshold') return { ids: [] as string[], startupCount: 0, conceptCount: 0 }
const ids: string[] = []
let startupCount = 0
let conceptCount = 0
for (const cat of ['STARTUP', 'BUSINESS_CONCEPT'] as const) {
for (const projectId of localOrder[cat]) {
const entry = rankingMap.get(projectId)
if (entry?.avgGlobalScore != null && entry.avgGlobalScore >= scoreThreshold) {
ids.push(projectId)
if (cat === 'STARTUP') startupCount++
else conceptCount++
}
}
}
return { ids, startupCount, conceptCount }
}, [advanceMode, scoreThreshold, localOrder, rankingMap])
// ─── handleAdvance ────────────────────────────────────────────────────────
function handleAdvance() {
let advanceIds: string[]
if (advanceMode === 'threshold') {
advanceIds = thresholdAdvanceIds.ids
} else {
advanceIds = [
...localOrder.STARTUP.slice(0, topNStartup),
...localOrder.BUSINESS_CONCEPT.slice(0, topNConceptual),
]
}
const advanceSet = new Set(advanceIds)
advanceMutation.mutate({ roundId, projectIds: advanceIds })
if (includeReject) {
const rejectIds = [...localOrder.STARTUP, ...localOrder.BUSINESS_CONCEPT].filter(
(id) => !advanceSet.has(id),
)
if (rejectIds.length > 0) {
batchRejectMutation.mutate({ projectIds: rejectIds, roundId, newState: 'REJECTED' })
}
}
}
// ─── handleExport ──────────────────────────────────────────────────────────
async function handleExportScores() {
setExportLoading(true)
@@ -758,18 +667,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
</>
)}
</Button>
<Button
size="sm"
disabled={saveReorderMutation.isPending || advanceMutation.isPending || !latestSnapshotId}
onClick={() => setAdvanceDialogOpen(true)}
className="bg-[#053d57] hover:bg-[#053d57]/90"
>
{advanceMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...</>
) : (
<><Trophy className="h-4 w-4 mr-2" /> Advance Top N</>
)}
</Button>
{/* Advance Top N removed — use Finalization tab instead */}
</div>
</CardHeader>
</Card>
@@ -1039,164 +937,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
))}
</div>
{/* Advance dialog */}
<Dialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Advance Projects</DialogTitle>
<DialogDescription>
Choose how to select which projects advance to the next round.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Mode toggle */}
<div className="flex gap-2">
<Button
size="sm"
variant={advanceMode === 'top_n' ? 'default' : 'outline'}
onClick={() => setAdvanceMode('top_n')}
className={advanceMode === 'top_n' ? 'bg-[#053d57] hover:bg-[#053d57]/90' : ''}
>
Top N per category
</Button>
<Button
size="sm"
variant={advanceMode === 'threshold' ? 'default' : 'outline'}
onClick={() => setAdvanceMode('threshold')}
className={advanceMode === 'threshold' ? 'bg-[#053d57] hover:bg-[#053d57]/90' : ''}
>
Score threshold
</Button>
</div>
{advanceMode === 'top_n' ? (
<>
{/* Top N for STARTUP */}
{localOrder.STARTUP.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Startups to advance</Label>
<Input
type="number"
min={0}
max={localOrder.STARTUP.length}
value={topNStartup}
onChange={(e) =>
setTopNStartup(
Math.max(0, Math.min(localOrder.STARTUP.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.STARTUP.length}</span>
</div>
)}
{/* Top N for BUSINESS_CONCEPT */}
{localOrder.BUSINESS_CONCEPT.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Concepts to advance</Label>
<Input
type="number"
min={0}
max={localOrder.BUSINESS_CONCEPT.length}
value={topNConceptual}
onChange={(e) =>
setTopNConceptual(
Math.max(0, Math.min(localOrder.BUSINESS_CONCEPT.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.BUSINESS_CONCEPT.length}</span>
</div>
)}
</>
) : (
<div className="space-y-3">
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Minimum avg score</Label>
<Input
type="number"
min={1}
max={10}
step={0.1}
value={scoreThreshold}
onChange={(e) => setScoreThreshold(Math.max(0, Math.min(10, parseFloat(e.target.value) || 5)))}
className="w-24"
/>
<span className="text-xs text-muted-foreground">out of 10</span>
</div>
<p className="text-xs text-muted-foreground">
All projects with an average global score at or above this threshold will advance, regardless of category.
</p>
</div>
)}
{/* Optional: also batch-reject non-advanced */}
<div className="flex items-center gap-2 pt-2 border-t">
<input
type="checkbox"
id="includeReject"
checked={includeReject}
onChange={(e) => setIncludeReject(e.target.checked)}
className="h-4 w-4 accent-[#de0f1e]"
/>
<Label htmlFor="includeReject" className="text-sm cursor-pointer">
Also batch-reject non-advanced projects
</Label>
</div>
{/* Preview */}
{(() => {
const advCount = advanceMode === 'top_n'
? topNStartup + topNConceptual
: thresholdAdvanceIds.ids.length
const totalProjects = localOrder.STARTUP.length + localOrder.BUSINESS_CONCEPT.length
return (
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-3">
<p>
Advancing: {advCount} project{advCount !== 1 ? 's' : ''}
{advanceMode === 'threshold' && (
<> ({thresholdAdvanceIds.startupCount} startups, {thresholdAdvanceIds.conceptCount} concepts)</>
)}
</p>
{includeReject && (
<p>Rejecting: {totalProjects - advCount} project{totalProjects - advCount !== 1 ? 's' : ''}</p>
)}
</div>
)
})()}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setAdvanceDialogOpen(false)}
disabled={advanceMutation.isPending || batchRejectMutation.isPending}
>
Cancel
</Button>
<Button
onClick={handleAdvance}
disabled={
advanceMutation.isPending ||
batchRejectMutation.isPending ||
(advanceMode === 'top_n' ? topNStartup + topNConceptual === 0 : thresholdAdvanceIds.ids.length === 0)
}
className="bg-[#053d57] hover:bg-[#053d57]/90"
>
{advanceMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...
</>
) : (
`Advance ${advanceMode === 'top_n' ? topNStartup + topNConceptual : thresholdAdvanceIds.ids.length} Project${(advanceMode === 'top_n' ? topNStartup + topNConceptual : thresholdAdvanceIds.ids.length) !== 1 ? 's' : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Advance dialog removed — use Finalization tab */}
{/* Side panel Sheet */}
<Sheet

View File

@@ -111,14 +111,15 @@ export async function processRoundClose(
let processed = 0
// Pre-compute pass set for EVALUATION rounds using ranking scores + config
// Pre-compute pass set for EVALUATION rounds using ranking scores + config.
// Respects admin drag-reorder overrides stored in reordersJson.
let evaluationPassSet: Set<string> | null = null
if ((round.roundType as RoundType) === 'EVALUATION') {
evaluationPassSet = new Set<string>()
const snapshot = await prisma.rankingSnapshot.findFirst({
where: { roundId },
orderBy: { createdAt: 'desc' as const },
select: { startupRankingJson: true, conceptRankingJson: true },
select: { startupRankingJson: true, conceptRankingJson: true, reordersJson: true },
})
if (snapshot) {
const config = (round.configJson as Record<string, unknown>) ?? {}
@@ -131,21 +132,40 @@ export async function processRoundClose(
const startupRanked = (snapshot.startupRankingJson ?? []) as RankEntry[]
const conceptRanked = (snapshot.conceptRankingJson ?? []) as RankEntry[]
// Apply admin drag-reorder overrides (reordersJson is append-only, latest per category wins)
type ReorderEvent = { category: 'STARTUP' | 'BUSINESS_CONCEPT'; orderedProjectIds: string[] }
const reorders = (snapshot.reordersJson as ReorderEvent[] | null) ?? []
const latestStartupReorder = [...reorders].reverse().find((r) => r.category === 'STARTUP')
const latestConceptReorder = [...reorders].reverse().find((r) => r.category === 'BUSINESS_CONCEPT')
// Build effective order: if admin reordered, use that; otherwise use computed rank order
const effectiveStartup = latestStartupReorder
? latestStartupReorder.orderedProjectIds
: [...startupRanked].sort((a, b) => a.rank - b.rank).map((r) => r.projectId)
const effectiveConcept = latestConceptReorder
? latestConceptReorder.orderedProjectIds
: [...conceptRanked].sort((a, b) => a.rank - b.rank).map((r) => r.projectId)
// Build score lookup for threshold mode
const scoreMap = new Map<string, number>()
for (const r of [...startupRanked, ...conceptRanked]) {
if (r.avgGlobalScore != null) scoreMap.set(r.projectId, r.avgGlobalScore)
}
if (advanceMode === 'threshold') {
for (const r of [...startupRanked, ...conceptRanked]) {
if (r.avgGlobalScore != null && r.avgGlobalScore >= advanceScoreThreshold) {
evaluationPassSet.add(r.projectId)
for (const id of [...effectiveStartup, ...effectiveConcept]) {
const score = scoreMap.get(id)
if (score != null && score >= advanceScoreThreshold) {
evaluationPassSet.add(id)
}
}
} else {
// 'count' mode — top N per category by rank
const sortedStartup = [...startupRanked].sort((a, b) => a.rank - b.rank)
const sortedConcept = [...conceptRanked].sort((a, b) => a.rank - b.rank)
for (let i = 0; i < Math.min(startupAdvanceCount, sortedStartup.length); i++) {
evaluationPassSet.add(sortedStartup[i].projectId)
// 'count' mode — top N per category using effective (possibly reordered) order
for (let i = 0; i < Math.min(startupAdvanceCount, effectiveStartup.length); i++) {
evaluationPassSet.add(effectiveStartup[i])
}
for (let i = 0; i < Math.min(conceptAdvanceCount, sortedConcept.length); i++) {
evaluationPassSet.add(sortedConcept[i].projectId)
for (let i = 0; i < Math.min(conceptAdvanceCount, effectiveConcept.length); i++) {
evaluationPassSet.add(effectiveConcept[i])
}
}
}