feat: observer UX overhaul — reports, projects, charts, session & email
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m2s
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:
@@ -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()])
|
||||
|
||||
Reference in New Issue
Block a user