Round system redesign: criteria voting, audience voting, pipeline view, and admin UX improvements

- Schema: Extend LiveVotingSession with votingMode, criteriaJson, audience fields;
  add AudienceVoter model; make LiveVote.userId nullable for audience voters
- Backend: Criteria-based voting with weighted scores, audience registration/voting
  with token-based dedup, configurable jury/audience weight in results
- Jury UI: Criteria scoring with per-criterion sliders alongside simple 1-10 mode
- Public audience voting page at /vote/[sessionId] with mobile-first design
- Admin live voting: Tabbed layout (Session/Config/Results), criteria config,
  audience settings, weight-adjustable results with tie detection
- Round type settings: Visual card selector replacing dropdown, feature tags
- Round detail page: Live event status section, type-specific stats and actions
- Round pipeline view: Horizontal visualization with bottleneck detection,
  List/Pipeline toggle on rounds page
- SSE: Separate jury/audience vote events, audience vote tracking
- Field visibility: Hide irrelevant fields per round type in create/edit forms

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 14:27:49 +01:00
parent b5d90d3c26
commit 2a5fa463b3
14 changed files with 2518 additions and 456 deletions

View File

@@ -33,6 +33,7 @@ export async function GET(request: NextRequest): Promise<Response> {
async start(controller) {
// Track state for change detection
let lastVoteCount = -1
let lastAudienceVoteCount = -1
let lastProjectId: string | null = null
let lastStatus: string | null = null
@@ -53,6 +54,7 @@ export async function GET(request: NextRequest): Promise<Response> {
currentProjectId: true,
currentProjectIndex: true,
votingEndsAt: true,
allowAudienceVotes: true,
},
})
@@ -86,19 +88,21 @@ export async function GET(request: NextRequest): Promise<Response> {
// Check for vote updates on the current project
if (currentSession.currentProjectId) {
const voteCount = await prisma.liveVote.count({
// Jury votes
const juryVoteCount = await prisma.liveVote.count({
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: false,
},
})
if (lastVoteCount !== -1 && voteCount !== lastVoteCount) {
// Get the latest vote info
if (lastVoteCount !== -1 && juryVoteCount !== lastVoteCount) {
const latestVotes = await prisma.liveVote.findMany({
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: false,
},
select: {
score: true,
@@ -113,6 +117,7 @@ export async function GET(request: NextRequest): Promise<Response> {
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: false,
},
_avg: { score: true },
_count: true,
@@ -120,13 +125,43 @@ export async function GET(request: NextRequest): Promise<Response> {
sendEvent('vote_update', {
projectId: currentSession.currentProjectId,
totalVotes: voteCount,
totalVotes: juryVoteCount,
averageScore: avgScore._avg.score,
latestVote: latestVotes[0] || null,
timestamp: new Date().toISOString(),
})
}
lastVoteCount = voteCount
lastVoteCount = juryVoteCount
// Audience votes (separate event)
if (currentSession.allowAudienceVotes) {
const audienceVoteCount = await prisma.liveVote.count({
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: true,
},
})
if (lastAudienceVoteCount !== -1 && audienceVoteCount !== lastAudienceVoteCount) {
const audienceAvg = await prisma.liveVote.aggregate({
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: true,
},
_avg: { score: true },
})
sendEvent('audience_vote', {
projectId: currentSession.currentProjectId,
audienceVotes: audienceVoteCount,
audienceAverage: audienceAvg._avg.score,
timestamp: new Date().toISOString(),
})
}
lastAudienceVoteCount = audienceVoteCount
}
}
// Stop polling if session is completed