feat: observer UX overhaul — reports, projects, charts, session & email
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m2s

- Observer projects: default sort by status (rejected last), sortable status column
- Observer projects: search by country, institution, geographic zone
- Observer project detail: vertical timeline connectors between rounds
- Fix React key warning in ExpandableJurorTable and FilteringReportTabs
- Fix ScoreBadge text always white for better contrast on all backgrounds
- Remove misleading /30 denominator from heatmap juror reviewed count
- INTAKE stats: show Start-ups, Business Concepts, Countries (not States/Categories)
- DiversityMetrics: extractCountry() for country-only display in charts
- Fix nested button hydration error in filtering report mobile view
- Color project titles by outcome in filtering report (green/red/amber)
- Redesign CrossStageComparisonChart: funnel viz + metrics table with attrition %
- Center doughnut chart in StatusBreakdownChart
- Remove redundant RoundTypeStatsCards from evaluation report
- Move evaluation tab bar below overview header, rename to "Juror Assignments"
- Dev email override system (DEV_EMAIL_OVERRIDE env var)
- Session refresh on role change without re-login
- Role switcher in user dropdown menu
- formatCategory() utility for consistent category display
- Activity feed max height constraint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 13:37:50 +01:00
parent e7b99fff63
commit a556732b46
23 changed files with 2108 additions and 326 deletions

View File

@@ -31,6 +31,13 @@ function evalWhere(input: { roundId?: string; programId?: string }, extra: Recor
return { ...base, ...extra }
}
/** Extract country from "City, Region, Country" location string */
function extractCountry(location: string | null): string {
if (!location) return 'Unknown'
const parts = location.split(',').map(s => s.trim())
return parts[parts.length - 1] || 'Unknown'
}
export const analyticsRouter = router({
/**
* Get score distribution (histogram data)
@@ -664,10 +671,10 @@ export const analyticsRouter = router({
return { total: 0, byCountry: [], byCategory: [], byOceanIssue: [], byTag: [] }
}
// By country
// By country (extract country from "City, Region, Country" format)
const countryCounts: Record<string, number> = {}
projects.forEach((p) => {
const key = p.country || 'Unknown'
const key = extractCountry(p.country)
countryCounts[key] = (countryCounts[key] || 0) + 1
})
const byCountry = Object.entries(countryCounts)
@@ -988,7 +995,7 @@ export const analyticsRouter = router({
roundId: z.string().optional(),
search: z.string().optional(),
status: z.string().optional(),
sortBy: z.enum(['title', 'score', 'evaluations']).default('title'),
sortBy: z.enum(['title', 'score', 'evaluations', 'status']).default('status'),
sortDir: z.enum(['asc', 'desc']).default('asc'),
page: z.number().min(1).default(1),
perPage: z.number().min(1).max(100).default(20),
@@ -1012,6 +1019,9 @@ export const analyticsRouter = router({
where.OR = [
{ title: { contains: input.search, mode: 'insensitive' } },
{ teamName: { contains: input.search, mode: 'insensitive' } },
{ country: { contains: input.search, mode: 'insensitive' } },
{ institution: { contains: input.search, mode: 'insensitive' } },
{ geographicZone: { contains: input.search, mode: 'insensitive' } },
{ teamMembers: { some: { user: { name: { contains: input.search, mode: 'insensitive' } } } } },
{ teamMembers: { some: { user: { email: { contains: input.search, mode: 'insensitive' } } } } },
]
@@ -1112,9 +1122,20 @@ export const analyticsRouter = router({
: mapped
const filteredTotal = observerStatusFilter ? filtered.length : total
// Sort by computed fields (score, evaluations) in JS
// Sort by computed fields (score, evaluations, status) in JS
const STATUS_ORDER: Record<string, number> = {
IN_PROGRESS: 0, PENDING: 1, COMPLETED: 2, PASSED: 3, REJECTED: 4, WITHDRAWN: 5,
}
let sorted = filtered
if (input.sortBy === 'score') {
if (input.sortBy === 'status') {
sorted = filtered.sort((a, b) => {
const oa = STATUS_ORDER[a.observerStatus] ?? 3
const ob = STATUS_ORDER[b.observerStatus] ?? 3
const cmp = oa - ob
if (cmp !== 0) return input.sortDir === 'asc' ? cmp : -cmp
return a.title.localeCompare(b.title)
})
} else if (input.sortBy === 'score') {
sorted = filtered.sort((a, b) => {
const sa = a.averageScore ?? -1
const sb = b.averageScore ?? -1
@@ -1129,7 +1150,7 @@ export const analyticsRouter = router({
}
// Paginate in JS for computed-field sorts or observer status filter
const needsJsPagination = input.sortBy !== 'title' || observerStatusFilter
const needsJsPagination = (input.sortBy !== 'title') || observerStatusFilter
const paginated = needsJsPagination
? sorted.slice((input.page - 1) * effectivePerPage, input.page * effectivePerPage)
: sorted
@@ -1159,7 +1180,7 @@ export const analyticsRouter = router({
switch (roundType) {
case 'INTAKE': {
const [total, byState, byCategory] = await Promise.all([
const [total, byState, byCategory, countryData] = await Promise.all([
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }),
ctx.prisma.projectRoundState.groupBy({
by: ['state'],
@@ -1171,11 +1192,21 @@ export const analyticsRouter = router({
where: { projectRoundStates: { some: { roundId: input.roundId } } },
_count: true,
}),
ctx.prisma.project.findMany({
where: { projectRoundStates: { some: { roundId: input.roundId } } },
select: { country: true },
}),
])
const countries = new Set(countryData.map((p) => extractCountry(p.country)).filter((c) => c !== 'Unknown'))
const startupCount = byCategory.find((c) => c.competitionCategory === 'STARTUP')?._count ?? 0
const conceptCount = byCategory.find((c) => c.competitionCategory === 'BUSINESS_CONCEPT')?._count ?? 0
return {
roundType,
stats: {
totalProjects: total,
startupCount,
conceptCount,
countryCount: countries.size,
byState: byState.map((s) => ({ state: s.state, count: s._count })),
byCategory: byCategory.map((c) => ({
category: c.competitionCategory ?? 'Uncategorized',
@@ -2013,15 +2044,14 @@ export const analyticsRouter = router({
eliminated: (prevByCategory.get(cat) ?? 0) - (currByCategory.get(cat) ?? 0),
}))
// Country attrition
const prevByCountry = new Map<string, number>()
prevProjects.forEach(p => {
const c = p.country ?? 'Unknown'
const c = extractCountry(p.country)
prevByCountry.set(c, (prevByCountry.get(c) ?? 0) + 1)
})
const currByCountry = new Map<string, number>()
currProjects.forEach(p => {
const c = p.country ?? 'Unknown'
const c = extractCountry(p.country)
currByCountry.set(c, (currByCountry.get(c) ?? 0) + 1)
})
const allCountries = new Set([...prevByCountry.keys(), ...currByCountry.keys()])