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

@@ -2,6 +2,19 @@ import nodemailer from 'nodemailer'
import type { Transporter } from 'nodemailer'
import { prisma } from '@/lib/prisma'
/**
* Dev email override: when DEV_EMAIL_OVERRIDE is set, ALL outgoing emails
* are redirected to that address. The original recipient is noted in the subject.
*/
const DEV_EMAIL_OVERRIDE = process.env.DEV_EMAIL_OVERRIDE || ''
async function sendEmail(opts: { to: string; subject: string; text: string; html: string }): Promise<void> {
const { transporter, from } = await getTransporter()
const to = DEV_EMAIL_OVERRIDE || opts.to
const subject = DEV_EMAIL_OVERRIDE ? `[DEV → ${opts.to}] ${opts.subject}` : opts.subject
await transporter.sendMail({ from, to, subject, text: opts.text, html: opts.html })
}
// Cached transporter and config hash to detect changes
let cachedTransporter: Transporter | null = null
let cachedConfigHash = ''
@@ -2253,15 +2266,7 @@ export async function sendStyledNotificationEmail(
)
}
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
// =============================================================================
@@ -2277,15 +2282,7 @@ export async function sendPasswordResetEmail(
expiryMinutes: number = 30
): Promise<void> {
const template = getPasswordResetTemplate(url, expiryMinutes)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
/**
@@ -2297,15 +2294,7 @@ export async function sendMagicLinkEmail(
): Promise<void> {
const expiryMinutes = parseInt(process.env.MAGIC_LINK_EXPIRY || '900') / 60
const template = getMagicLinkTemplate(url, expiryMinutes)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
/**
@@ -2319,15 +2308,7 @@ export async function sendInvitationEmail(
expiryHours?: number
): Promise<void> {
const template = getGenericInvitationTemplate(name || '', url, role, expiryHours)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
/**
@@ -2340,15 +2321,7 @@ export async function sendJuryInvitationEmail(
roundName: string
): Promise<void> {
const template = getJuryInvitationTemplate(name || '', url, roundName)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
/**
@@ -2369,15 +2342,7 @@ export async function sendEvaluationReminderEmail(
deadline,
assignmentsUrl
)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
/**
@@ -2398,15 +2363,7 @@ export async function sendAnnouncementEmail(
ctaText,
ctaUrl
)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
/**
@@ -2422,10 +2379,7 @@ export async function sendTestEmail(toEmail: string): Promise<boolean> {
Sent at ${new Date().toISOString()}
</p>
`
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
await sendEmail({
to: toEmail,
subject: 'MOPC Portal - Test Email',
text: 'This is a test email from the MOPC Portal. If you received this, your email configuration is working correctly.',
@@ -2466,15 +2420,7 @@ export async function sendApplicationConfirmationEmail(
programName,
customMessage
)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
/**
@@ -2493,15 +2439,7 @@ export async function sendTeamMemberInviteEmail(
teamLeadName,
inviteUrl
)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}
/**
@@ -2561,13 +2499,5 @@ export async function sendNotificationEmail(
linkUrl?: string
): Promise<void> {
const template = getNotificationEmailTemplate(name, subject, body, ensureAbsoluteUrl(linkUrl))
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
await sendEmail({ to: email, subject: template.subject, text: template.text, html: template.html })
}