Observer dashboard extraction, PDF reports, jury UX overhaul, and miscellaneous improvements

- Extract observer dashboard to client component, add PDF export button
- Add PDF report generator with jsPDF for analytics reports
- Overhaul jury evaluation page with improved layout and UX
- Add new analytics endpoints for observer/admin reports
- Improve round creation/edit forms with better settings
- Fix filtering rules page, CSV export dialog, notification bell
- Update auth, prisma schema, and various type fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 23:08:00 +01:00
parent 5c8d22ac11
commit d787a24921
31 changed files with 2565 additions and 930 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
@@ -140,9 +140,11 @@ type Notification = {
function NotificationItem({
notification,
onRead,
observeRef,
}: {
notification: Notification
onRead: () => void
observeRef?: (el: HTMLDivElement | null) => void
}) {
const IconComponent = ICON_MAP[notification.icon || 'Bell'] || Bell
const priorityStyle =
@@ -151,6 +153,8 @@ function NotificationItem({
const content = (
<div
ref={observeRef}
data-notification-id={notification.id}
className={cn(
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20'
@@ -250,17 +254,86 @@ export function NotificationBell() {
onSuccess: () => refetch(),
})
// Auto-mark all notifications as read when popover opens
useEffect(() => {
if (open && (countData ?? 0) > 0) {
markAllAsReadMutation.mutate()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const markBatchAsReadMutation = trpc.notification.markBatchAsRead.useMutation({
onSuccess: () => refetch(),
})
const unreadCount = countData ?? 0
const notifications = notificationData?.notifications ?? []
// Track unread notification IDs that have become visible
const pendingReadIds = useRef<Set<string>>(new Set())
const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// Flush pending read IDs in a batch
const flushPendingReads = useCallback(() => {
if (pendingReadIds.current.size === 0) return
const ids = Array.from(pendingReadIds.current)
pendingReadIds.current.clear()
markBatchAsReadMutation.mutate({ ids })
}, [markBatchAsReadMutation])
// Set up IntersectionObserver when popover opens
useEffect(() => {
if (!open) {
// Flush any remaining on close
if (flushTimer.current) clearTimeout(flushTimer.current)
flushPendingReads()
observerRef.current?.disconnect()
observerRef.current = null
return
}
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = (entry.target as HTMLElement).dataset.notificationId
if (id) {
// Check if this notification is unread
const notif = notifications.find((n) => n.id === id)
if (notif && !notif.isRead) {
pendingReadIds.current.add(id)
}
}
}
}
// Debounce the batch call
if (pendingReadIds.current.size > 0) {
if (flushTimer.current) clearTimeout(flushTimer.current)
flushTimer.current = setTimeout(flushPendingReads, 500)
}
},
{ threshold: 0.5 }
)
// Observe all currently tracked items
itemRefs.current.forEach((el) => observerRef.current?.observe(el))
return () => {
observerRef.current?.disconnect()
if (flushTimer.current) clearTimeout(flushTimer.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, notifications])
// Ref callback for each notification item
const getItemRef = useCallback(
(id: string) => (el: HTMLDivElement | null) => {
if (el) {
itemRefs.current.set(id, el)
observerRef.current?.observe(el)
} else {
const prev = itemRefs.current.get(id)
if (prev) observerRef.current?.unobserve(prev)
itemRefs.current.delete(id)
}
},
[]
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -339,6 +412,7 @@ export function NotificationBell() {
<NotificationItem
key={notification.id}
notification={notification}
observeRef={!notification.isRead ? getItemRef(notification.id) : undefined}
onRead={() => {
if (!notification.isRead) {
markAsReadMutation.mutate({ id: notification.id })