fix: build speed, observer AI details, round tracker empty state
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Disable typedRoutes and skip TS in build (run tsc separately) — build drops from ~9min to ~36s - Expand optimizePackageImports for sonner, date-fns, recharts, motion, zod - Docker: mount .next/cache as build cache for faster rebuilds - Observer filtering panel: fix AI reasoning extraction (nested under rule ID) and show confidence, quality score, spam risk, override reason - Round User Tracker: show empty state message instead of disappearing when selected round has no passed projects yet Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,9 +23,9 @@ COPY . .
|
|||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Build Next.js
|
# Build Next.js — mount .next/cache as a Docker build cache for faster rebuilds
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
RUN npm run build
|
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|||||||
@@ -2,10 +2,21 @@ import type { NextConfig } from 'next'
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
typedRoutes: true,
|
|
||||||
serverExternalPackages: ['@prisma/client', 'minio'],
|
serverExternalPackages: ['@prisma/client', 'minio'],
|
||||||
|
typescript: {
|
||||||
|
// We run tsc --noEmit separately before each push
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ['lucide-react'],
|
optimizePackageImports: [
|
||||||
|
'lucide-react',
|
||||||
|
'sonner',
|
||||||
|
'date-fns',
|
||||||
|
'recharts',
|
||||||
|
'motion/react',
|
||||||
|
'zod',
|
||||||
|
'@radix-ui/react-icons',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
|||||||
@@ -57,23 +57,21 @@ export function RoundUserTracker({ editionId }: RoundUserTrackerProps) {
|
|||||||
const { rounds, byCategory } = data
|
const { rounds, byCategory } = data
|
||||||
const effectiveRoundId = data.selectedRoundId
|
const effectiveRoundId = data.selectedRoundId
|
||||||
|
|
||||||
// Don't render if no rounds or no data
|
// Don't render if no rounds at all
|
||||||
if (!effectiveRoundId || rounds.length === 0) return null
|
if (rounds.length === 0) return null
|
||||||
|
|
||||||
const totalProjects = byCategory.reduce((sum, c) => sum + c.total, 0)
|
const totalProjects = byCategory.reduce((sum, c) => sum + c.total, 0)
|
||||||
const totalActivated = byCategory.reduce((sum, c) => sum + c.accountsSet, 0)
|
const totalActivated = byCategory.reduce((sum, c) => sum + c.accountsSet, 0)
|
||||||
const totalPending = byCategory.reduce((sum, c) => sum + c.accountsNotSet, 0)
|
const totalPending = byCategory.reduce((sum, c) => sum + c.accountsNotSet, 0)
|
||||||
|
|
||||||
if (totalProjects === 0) return null
|
const selectedRound = effectiveRoundId ? rounds.find(r => r.id === effectiveRoundId) : undefined
|
||||||
|
|
||||||
const selectedRound = rounds.find(r => r.id === effectiveRoundId)
|
|
||||||
|
|
||||||
const handleSendReminder = async (target: string, opts: { category?: 'STARTUP' | 'BUSINESS_CONCEPT' }) => {
|
const handleSendReminder = async (target: string, opts: { category?: 'STARTUP' | 'BUSINESS_CONCEPT' }) => {
|
||||||
setSendingTarget(target)
|
setSendingTarget(target)
|
||||||
try {
|
try {
|
||||||
await sendReminders.mutateAsync({
|
await sendReminders.mutateAsync({
|
||||||
editionId,
|
editionId,
|
||||||
roundId: effectiveRoundId,
|
roundId: effectiveRoundId!,
|
||||||
category: opts.category,
|
category: opts.category,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@@ -89,13 +87,15 @@ export function RoundUserTracker({ editionId }: RoundUserTrackerProps) {
|
|||||||
<Users className="h-4 w-4 text-brand-blue" />
|
<Users className="h-4 w-4 text-brand-blue" />
|
||||||
Round User Tracker
|
Round User Tracker
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
{totalProjects > 0 && (
|
||||||
<Badge variant="outline" className="text-xs shrink-0">
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
{totalActivated}/{totalProjects} activated
|
{totalActivated}/{totalProjects} activated
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Round selector */}
|
{/* Round selector */}
|
||||||
<Select
|
<Select
|
||||||
value={effectiveRoundId}
|
value={effectiveRoundId ?? ''}
|
||||||
onValueChange={(val) => setSelectedRoundId(val)}
|
onValueChange={(val) => setSelectedRoundId(val)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs mt-2">
|
<SelectTrigger className="h-8 text-xs mt-2">
|
||||||
@@ -114,6 +114,15 @@ export function RoundUserTracker({ editionId }: RoundUserTrackerProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{totalProjects === 0 ? (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<Users className="h-8 w-8 text-muted-foreground/30 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No projects have passed {selectedRound?.name ?? 'this round'} yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Subtitle showing round context */}
|
{/* Subtitle showing round context */}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Projects that passed <span className="font-medium">{selectedRound?.name ?? 'this round'}</span> — account activation status
|
Projects that passed <span className="font-medium">{selectedRound?.name ?? 'this round'}</span> — account activation status
|
||||||
@@ -192,6 +201,8 @@ export function RoundUserTracker({ editionId }: RoundUserTrackerProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,29 @@ import {
|
|||||||
import { Filter, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { Filter, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type AIScreeningData = {
|
||||||
|
meetsCriteria?: boolean
|
||||||
|
confidence?: number
|
||||||
|
reasoning?: string
|
||||||
|
qualityScore?: number
|
||||||
|
spamRisk?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAIData(json: unknown): AIScreeningData | null {
|
||||||
|
if (!json || typeof json !== 'object') return null
|
||||||
|
const obj = json as Record<string, unknown>
|
||||||
|
// aiScreeningJson is nested under rule ID: { [ruleId]: { outcome, confidence, ... } }
|
||||||
|
if (!('outcome' in obj) && !('reasoning' in obj)) {
|
||||||
|
const keys = Object.keys(obj)
|
||||||
|
if (keys.length > 0) {
|
||||||
|
const inner = obj[keys[0]]
|
||||||
|
if (inner && typeof inner === 'object') return inner as AIScreeningData
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return obj as unknown as AIScreeningData
|
||||||
|
}
|
||||||
|
|
||||||
export function FilteringPanel({ roundId }: { roundId: string }) {
|
export function FilteringPanel({ roundId }: { roundId: string }) {
|
||||||
const [outcomeFilter, setOutcomeFilter] = useState<string>('ALL')
|
const [outcomeFilter, setOutcomeFilter] = useState<string>('ALL')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
@@ -199,17 +222,39 @@ export function FilteringPanel({ roundId }: { roundId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{expandedId === r.id && (
|
{expandedId === r.id && (() => {
|
||||||
<div className="px-4 pb-3 pt-0">
|
const ai = parseAIData(r.aiScreeningJson)
|
||||||
<div className="rounded bg-muted/50 p-3 text-xs leading-relaxed text-muted-foreground">
|
return (
|
||||||
{(() => {
|
<div className="px-4 pb-3 pt-0 space-y-2">
|
||||||
const screening = r.aiScreeningJson as Record<string, unknown> | null
|
{ai?.confidence != null && (
|
||||||
const reasoning = (screening?.reasoning ?? screening?.explanation ?? r.overrideReason ?? 'No details available') as string
|
<div className="flex items-center gap-3 text-xs">
|
||||||
return reasoning
|
{ai.confidence != null && (
|
||||||
})()}
|
<span className="text-muted-foreground">
|
||||||
</div>
|
Confidence: <strong>{Math.round(ai.confidence * 100)}%</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ai.qualityScore != null && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Quality: <strong>{ai.qualityScore}/10</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ai.spamRisk && (
|
||||||
|
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">Spam Risk</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="rounded bg-muted/50 border p-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap">
|
||||||
|
{ai?.reasoning || 'No AI reasoning available'}
|
||||||
|
</div>
|
||||||
|
{r.overrideReason && (
|
||||||
|
<div className="rounded bg-amber-50 border border-amber-200 p-3 text-xs">
|
||||||
|
<span className="font-medium text-amber-800">Override: </span>
|
||||||
|
{r.overrideReason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user