diff --git a/README.md b/README.md
index 3af4a1866..3d05adbc4 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,30 @@ For development, besides the JDK, addtitionally [NPM](https://www.npmjs.com/) an
We kindly ask you to refer to the following paper in publications mentioning or employing DRES:
+> Loris Sauter, Ralph Gasser, Heiko Schuldt, Abraham Bernstein, and Luca Rossetto. 2024. Performance Evaluation in Multimedia Retrieval. ACM Transactions on Multimedia Computing, Communications and Applications. https://doi.org/10.1145/3678881
+
+**Link:** https://dl.acm.org/doi/10.1145/3678881
+
+**Bibtex:**
+
+```
+@article{10.1145/3678881,
+author = {Sauter, Loris and Gasser, Ralph and Schuldt, Heiko and Bernstein, Abraham and Rossetto, Luca},
+title = {Performance Evaluation in Multimedia Retrieval},
+year = {2024},
+publisher = {Association for Computing Machinery},
+address = {New York, NY, USA},
+issn = {1551-6857},
+url = {https://doi.org/10.1145/3678881},
+doi = {10.1145/3678881},
+journal = {ACM Transactions on Multimedia Computing, Communications and Applications},
+month = oct,
+keywords = {Interactive Multimedia Retrieval, Retrieval Evaluation, Interactive Evaluation, Evaluation System}
+}
+```
+
+We also have a demo publication:
+
> Rossetto L., Gasser R., Sauter L., Bernstein A., Schuldt H. (2021) A System for Interactive Multimedia Retrieval Evaluations. In: Lokoč J. et al. (eds) MultiMedia Modeling. MMM 2021. Lecture Notes in Computer Science, vol 12573. Springer, Cham.
**Link:** https://doi.org/10.1007/978-3-030-67835-7_33
diff --git a/frontend/src/app/error/error-dialog.service.ts b/frontend/src/app/error/error-dialog.service.ts
index 019cdcdaf..820aaad63 100644
--- a/frontend/src/app/error/error-dialog.service.ts
+++ b/frontend/src/app/error/error-dialog.service.ts
@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { MatDialog } from "@angular/material/dialog";
import { ErrorDialogComponent } from "./error-dialog/error-dialog.component";
+import { environment } from "../../environments/environment";
@Injectable()
export class ErrorDialogService {
@@ -12,6 +13,11 @@ export class ErrorDialogService {
}
openDialog(message: string):void {
+ if (!environment.showErrorPopups) {
+ console.warn('[Silent Error]:', message);
+ return;
+ }
+
if(!this.opened){
this.opened = true;
const dialogRef = this.dialog.open(ErrorDialogComponent, {
diff --git a/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.html b/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.html
new file mode 100644
index 000000000..2955903ea
--- /dev/null
+++ b/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.html
@@ -0,0 +1,31 @@
+
\ No newline at end of file
diff --git a/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.scss b/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.scss
new file mode 100644
index 000000000..aa2f82db1
--- /dev/null
+++ b/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.scss
@@ -0,0 +1,92 @@
+:host {
+ display: block;
+ position: relative;
+ height: 100%;
+ width: 100%;
+}
+
+.graph-wrapper {
+ position: absolute;
+ top: 0; bottom: 0; left: 0; right: 0;
+ display: flex;
+ flex-direction: column;
+ background: rgba(0, 0, 0, 0.1);
+ padding: 10px;
+ box-sizing: border-box;
+}
+
+/* --- LEGEND --- */
+.legend-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 15px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #444;
+ margin-bottom: 8px;
+ flex-shrink: 0;
+}
+
+.legend-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+
+ .color-swatch { width: 10px; height: 10px; border-radius: 2px; }
+ .group-name { color: #ccc; font-size: 11px; text-transform: uppercase; font-weight: 600; }
+}
+
+/* --- SCROLLABLE CHART --- */
+.chart-scroll-area {
+ flex: 1;
+ overflow-y: auto;
+ padding-right: 5px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.chart-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 12px;
+
+ &:hover .track { filter: brightness(1.2); }
+}
+
+.row-label {
+ width: 55px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+
+ .name {
+ flex: 1;
+ font-size: 11px;
+ color: #bbb;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.track-container {
+ flex: 1;
+}
+
+.track {
+ width: 100%;
+ height: 8px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 1px;
+ display: flex;
+ overflow: hidden;
+}
+
+.segment {
+ height: 100%;
+ transition: width 0.4s ease-in-out;
+ cursor: pointer;
+
+ &:hover { filter: brightness(1.3); }
+}
\ No newline at end of file
diff --git a/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.ts b/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.ts
new file mode 100644
index 000000000..ee33823bb
--- /dev/null
+++ b/frontend/src/app/viewer/compact-score-graph/compact-score-graph.component.ts
@@ -0,0 +1,80 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { Observable, combineLatest, of } from 'rxjs';
+import { catchError, map, switchMap, filter } from 'rxjs/operators';
+import { ApiEvaluationInfo, ApiEvaluationState, EvaluationScoresService } from '../../../../openapi';
+
+@Component({
+ selector: 'app-compact-score-graph',
+ templateUrl: './compact-score-graph.component.html',
+ styleUrls: ['./compact-score-graph.component.scss']
+})
+export class CompactScoreGraphComponent implements OnInit {
+ @Input() info: Observable;
+ @Input() state: Observable;
+
+ graphData$: Observable;
+
+ colorPalette = [
+ '#9b59b6', '#3498db', '#e67e22', '#2ecc71', '#e74c3c',
+ '#1abc9c', '#f1c40f', '#34495e', '#ff9ff3', '#feca57',
+ '#ff6b6b', '#48dbfb', '#1dd1a1', '#5f27cd', '#c8d6e5',
+ '#22a6b3', '#badc58', '#eb4d4b', '#686de0', '#30336b'
+ ];
+
+ constructor(private scoreService: EvaluationScoresService) {}
+
+ ngOnInit(): void {
+ const rawScores$ = this.state.pipe(
+ filter(s => !!s && !!s.evaluationId),
+ switchMap(s => this.scoreService.getApiV2ScoreEvaluationByEvaluationId(s.evaluationId).pipe(
+ catchError(() => of([]))
+ ))
+ );
+
+ this.graphData$ = combineLatest([this.info, rawScores$]).pipe(
+ map(([info, scores]) => {
+ if (!info || !info.teams || !scores) return null;
+
+ const validGroups = scores.filter(s => s.name !== 'sum' && s.name !== 'average');
+ const legend = validGroups.map((g, i) => ({
+ name: g.name,
+ color: this.colorPalette[i % this.colorPalette.length]
+ }));
+
+ let globalMaxScore = 0;
+
+ const teamsData = info.teams.map(team => {
+ let totalScore = 0;
+ const segments: any[] = [];
+
+ validGroups.forEach((group, index) => {
+ const teamScoreObj = group.scores?.find(ss => ss.teamId === team.id);
+ const value = teamScoreObj ? Math.round(teamScoreObj.score) : 0;
+
+ totalScore += value;
+ segments.push({
+ name: group.name,
+ value: value,
+ color: this.colorPalette[index % this.colorPalette.length]
+ });
+ });
+
+ if (totalScore > globalMaxScore) globalMaxScore = totalScore;
+
+ return { name: team.name, total: totalScore, segments };
+ });
+
+ teamsData.sort((a, b) => b.total - a.total);
+ const chartScaleMax = Math.max(globalMaxScore, 1000);
+
+ teamsData.forEach(team => {
+ team.segments.forEach(seg => {
+ seg.widthInPercent = (seg.value / chartScaleMax) * 100;
+ });
+ });
+
+ return { teams: teamsData, legend: legend };
+ })
+ );
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.html b/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.html
new file mode 100644
index 000000000..95048344c
--- /dev/null
+++ b/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.html
@@ -0,0 +1,30 @@
+
+
+
+
0"
+ [class.status-wrong]="team.wrong > 0 && team.correct === 0"
+ [class.gold]="team.rank === 1 && team.score > 0"
+ [class.silver]="team.rank === 2 && team.score > 0"
+ [class.bronze]="team.rank === 3 && team.score > 0"
+ >
+
+
+
+ {{ team.score | number: '1.0-0' }}
+
+
+
+ {{ team.correct }}
+ {{ team.wrong }}
+ {{ team.indeterminate }}
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.scss b/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.scss
new file mode 100644
index 000000000..74afc8436
--- /dev/null
+++ b/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.scss
@@ -0,0 +1,95 @@
+:host {
+ display: block;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+.compact-teams-container {
+ height: 100%;
+ padding: 8px;
+ box-sizing: border-box;
+ overflow-y: auto; /* Scroll if 50 teams don't fit vertically */
+}
+
+.teams-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
+ gap: 6px;
+ align-content: start;
+}
+
+.tile {
+ min-height: 70px;
+ padding: 4px;
+ border-radius: 6px;
+ border: 2px solid #333;
+ background: #2c2c2e;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+
+ .tile-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: rgba(0, 0, 0, 0.2);
+ padding: 2px 4px;
+ border-radius: 4px;
+
+ .rank-badge {
+ background: #444;
+ color: #fff;
+ font-size: 10px;
+ font-weight: bold;
+ padding: 2px 4px;
+ border-radius: 4px;
+ flex-shrink: 0;
+ }
+
+ h3 {
+ font-size: clamp(10px, 1.1vw, 13px);
+ margin: 0;
+ font-weight: 600;
+ color: #eee;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ p.score {
+ font-size: clamp(16px, 1.8vw, 20px);
+ font-weight: bold;
+ color: #fff;
+ margin: 4px 0;
+ text-align: center;
+ &.dres-fix-correct { color: #00ff9d; }
+ }
+
+ p.counter {
+ font-size: 11px;
+ font-weight: 600;
+ margin: 0;
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+
+ span.CORRECT { color: #2ecc71; }
+ span.WRONG { color: #e74c3c; }
+ span.INDETERMINATE { color: #95a5a6; }
+ }
+
+ &.status-correct {
+ background: radial-gradient(circle at top left, rgba(46, 204, 113, 0.45) 0%, rgba(46, 204, 113, 0) 100%), #2c2c2e;
+ border-color: rgba(46, 204, 113, 0.8);
+ }
+ &.status-wrong {
+ background: radial-gradient(circle at top left, rgba(231, 76, 60, 0.45) 0%, rgba(231, 76, 60, 0) 100%), #2c2c2e;
+ border-color: rgba(231, 76, 60, 0.8);
+ }
+ &.gold { border-color: #FFD700; .tile-header .rank-badge { background: #FFD700; color: #000; } }
+ &.silver { border-color: #C0C0C0; .tile-header .rank-badge { background: #C0C0C0; color: #000; } }
+ &.bronze { border-color: #CD7F32; .tile-header .rank-badge { background: #CD7F32; color: #000; } }
+}
\ No newline at end of file
diff --git a/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.ts b/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.ts
new file mode 100644
index 000000000..2a4d85c73
--- /dev/null
+++ b/frontend/src/app/viewer/compact-teams-viewer/compact-teams-viewer.component.ts
@@ -0,0 +1,91 @@
+import { Component, Input, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, OnDestroy } from '@angular/core';
+import { Observable, combineLatest, of } from 'rxjs';
+import { catchError, map, switchMap, sampleTime, shareReplay, retry, startWith } from 'rxjs/operators';
+import { ApiEvaluationInfo, ApiEvaluationState, EvaluationScoresService, EvaluationService } from '../../../../openapi';
+
+@Component({
+ selector: 'app-compact-teams-viewer',
+ templateUrl: './compact-teams-viewer.component.html',
+ styleUrls: ['./compact-teams-viewer.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class CompactTeamsViewerComponent implements OnInit, OnDestroy {
+ @Input() info: Observable;
+ @Input() state: Observable;
+ @Input() taskEnded: Observable;
+
+ teamsData$: Observable;
+ private intervalId: any;
+
+ constructor(
+ private evaluationService: EvaluationService,
+ private scoresService: EvaluationScoresService,
+ private ref: ChangeDetectorRef
+ ) {
+ this.ref.detach();
+ this.intervalId = setInterval(() => this.ref.detectChanges(), 500);
+ }
+
+ ngOnInit(): void {
+ // Fetch submissions every 2 seconds
+ const submissions$ = this.state.pipe(
+ sampleTime(2000),
+ switchMap(st => this.evaluationService.getApiV2EvaluationByEvaluationIdSubmissionList(st.evaluationId).pipe(
+ catchError(() => of([]))
+ )),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
+
+ const scores$ = this.state.pipe(
+ switchMap(st => this.scoresService.getApiV2ScoreEvaluationByEvaluationIdCurrent(st.evaluationId).pipe(
+ retry(3),
+ catchError(() => of(null))
+ )),
+ map(sc => {
+ const scoreMap = new Map();
+ if (sc && sc.scores) {
+ sc.scores.forEach(v => scoreMap.set(v.teamId, v.score));
+ }
+ return scoreMap;
+ }),
+ startWith(new Map()),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
+
+ // Combine everything into a single array for the UI
+ this.teamsData$ = combineLatest([this.info, scores$, submissions$]).pipe(
+ map(([info, scoreMap, submissions]) => {
+ if (!info || !info.teams) return [];
+
+ const teamsWithScores = info.teams.map(team => {
+ const teamSubmissions = submissions.filter(s => s.teamId === team.id).flatMap(s => s.answers);
+
+ return {
+ id: team.id,
+ name: team.name,
+ score: scoreMap.get(team.id) || 0,
+ correct: teamSubmissions.filter(a => a.status === 'CORRECT').length,
+ wrong: teamSubmissions.filter(a => a.status === 'WRONG').length,
+ indeterminate: teamSubmissions.filter(a => a.status === 'INDETERMINATE').length,
+ rank: 0 // to be filled
+ };
+ });
+
+ // Sort a copy by score to determine true ranks
+ const sortedByScore = [...teamsWithScores].sort((a, b) => b.score - a.score);
+ sortedByScore.forEach((team, index) => {
+ const original = teamsWithScores.find(t => t.id === team.id);
+ if (original) {
+ original.rank = index + 1;
+ }
+ });
+
+ return teamsWithScores;
+ })
+ );
+ }
+
+ ngOnDestroy(): void {
+ clearInterval(this.intervalId);
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.html b/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.html
new file mode 100644
index 000000000..98f7dbe75
--- /dev/null
+++ b/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.html
@@ -0,0 +1,19 @@
+
+
+
+
+
0"
+ [class.silver]="i === 1 && player.totalScore > 0"
+ [class.bronze]="i === 2 && player.totalScore > 0"
+ >
+
{{ i + 1 }}
+
{{ player.userName }}
+
{{ player.totalScore }}
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.scss b/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.scss
new file mode 100644
index 000000000..4bc407eea
--- /dev/null
+++ b/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.scss
@@ -0,0 +1,74 @@
+:host {
+ display: block;
+ position: relative;
+ height: 100%;
+ width: 100%;
+}
+
+.leaderboard-wrapper {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 10px;
+ box-sizing: border-box;
+ overflow-y: auto; /* Scrollbar */
+}
+
+.leaderboard-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
+ gap: 6px;
+ align-content: start;
+}
+
+.player-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 4px 6px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 4px;
+ border-left: 3px solid #444;
+
+ .rank {
+ width: 20px;
+ font-size: 11px;
+ font-weight: bold;
+ color: #888;
+ }
+
+ .name {
+ flex: 1;
+ font-size: 12px;
+ color: #eee;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ padding: 0 4px;
+ }
+
+ .score {
+ font-size: 13px;
+ font-weight: bold;
+ color: #fff;
+ text-align: right;
+ }
+
+ &.gold {
+ border-left-color: #FFD700;
+ background: linear-gradient(90deg, rgba(255, 215, 0, 0.15), transparent);
+ .rank { color: #FFD700; }
+ }
+ &.silver {
+ border-left-color: #C0C0C0;
+ background: linear-gradient(90deg, rgba(192, 192, 192, 0.15), transparent);
+ .rank { color: #C0C0C0; }
+ }
+ &.bronze {
+ border-left-color: #CD7F32;
+ background: linear-gradient(90deg, rgba(205, 127, 50, 0.15), transparent);
+ .rank { color: #CD7F32; }
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.ts b/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.ts
new file mode 100644
index 000000000..900fe7ada
--- /dev/null
+++ b/frontend/src/app/viewer/leaderboard-viewer/leaderboard-viewer.component.ts
@@ -0,0 +1,47 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { Observable, combineLatest, of } from 'rxjs';
+import { catchError, map, switchMap, filter } from 'rxjs/operators';
+import { ApiEvaluationInfo, ApiEvaluationState, EvaluationScoresService } from '../../../../openapi';
+
+@Component({
+ selector: 'app-leaderboard-viewer',
+ templateUrl: './leaderboard-viewer.component.html',
+ styleUrls: ['./leaderboard-viewer.component.scss']
+})
+export class LeaderboardViewerComponent implements OnInit {
+ @Input() info: Observable;
+ @Input() state: Observable;
+
+ playerData$: Observable;
+
+ constructor(private scoreService: EvaluationScoresService) {}
+
+ ngOnInit(): void {
+ const rawScores$ = this.state.pipe(
+ filter(s => !!s && !!s.evaluationId),
+ switchMap(s => this.scoreService.getApiV2ScoreEvaluationByEvaluationId(s.evaluationId).pipe(
+ catchError(() => of([]))
+ ))
+ );
+
+ this.playerData$ = combineLatest([this.info, rawScores$]).pipe(
+ map(([info, scores]) => {
+ if (!info || !info.teams || !scores || scores.length === 0) return [];
+
+ const players = info.teams.map(team => {
+ let totalScore = 0;
+ // Tally up the total score from all groups, ignoring the 'sum' and 'average' meta-fields
+ scores.filter(s => s.name !== 'sum' && s.name !== 'average').forEach(group => {
+ const teamScoreObj = group.scores?.find(ss => ss.teamId === team.id);
+ if (teamScoreObj) totalScore += Math.round(teamScoreObj.score);
+ });
+
+ return { userName: team.name, totalScore: totalScore };
+ });
+
+ // Return ALL players, sorted highest to lowest
+ return players.sort((a, b) => b.totalScore - a.totalScore);
+ })
+ );
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/viewer/model/run-viewer-preset.ts b/frontend/src/app/viewer/model/run-viewer-preset.ts
new file mode 100644
index 000000000..95cdc8f1a
--- /dev/null
+++ b/frontend/src/app/viewer/model/run-viewer-preset.ts
@@ -0,0 +1,10 @@
+export interface ViewerPreset {
+ name: string;
+ icon: string;
+ config: {
+ left: string;
+ center: string;
+ right: string;
+ bottom: string;
+ };
+}
\ No newline at end of file
diff --git a/frontend/src/app/viewer/model/run-viewer-widgets.ts b/frontend/src/app/viewer/model/run-viewer-widgets.ts
index 1013e87f3..a43b5101a 100644
--- a/frontend/src/app/viewer/model/run-viewer-widgets.ts
+++ b/frontend/src/app/viewer/model/run-viewer-widgets.ts
@@ -16,10 +16,16 @@ export class Widget {
new Widget('player', 'Player'),
new Widget('competition_score', 'Normalized Competition Scores'),
new Widget('task_type_score', 'Task Type Score'),
+ new Widget('scoreboard', 'Scoreboard'),
+ new Widget('recent_submissions', 'Recent Submissions'),
+ new Widget('compact_score_graph', 'Compact Score Graph'),
];
/** The {@link Widget}s available at the bottom of the viewer. */
- public static BOTTOM_WIDGETS: Array = [new Widget('team_score', 'Team Scores')];
+ public static BOTTOM_WIDGETS: Array = [
+ new Widget('team_score', 'Team Scores'),
+ new Widget('compact_team_score', 'Compact Team Scores'),
+ ];
/** Given a name and a default, this resolves and returns a {@link Widget} of the group {@link CENTER_WIDGETS} */
public static resolveCenterWidget(name: string, fallback: string) {
diff --git a/frontend/src/app/viewer/recent-submissions/recent-submissions.component.html b/frontend/src/app/viewer/recent-submissions/recent-submissions.component.html
new file mode 100644
index 000000000..3f49e8e6b
--- /dev/null
+++ b/frontend/src/app/viewer/recent-submissions/recent-submissions.component.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+ "{{ sub.previewText }}"
+
+
+
+
+
+
+ {{ sub.teamName }}
+ {{ sub.relativeTime }}
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/app/viewer/recent-submissions/recent-submissions.component.scss b/frontend/src/app/viewer/recent-submissions/recent-submissions.component.scss
new file mode 100644
index 000000000..2d6780d3a
--- /dev/null
+++ b/frontend/src/app/viewer/recent-submissions/recent-submissions.component.scss
@@ -0,0 +1,115 @@
+:host {
+ display: block;
+ position: relative;
+ height: 100%;
+ width: 100%;
+}
+
+.feed-wrapper {
+ position: absolute;
+ top: 0; bottom: 0; left: 0; right: 0;
+ display: flex;
+ flex-direction: column;
+ background: rgba(0, 0, 0, 0.2);
+}
+
+.feed-header {
+ padding: 8px 12px;
+ background: #222;
+ border-bottom: 1px solid #444;
+ flex-shrink: 0;
+
+ h3 {
+ margin: 0;
+ font-size: 13px;
+ color: #eee;
+ text-transform: uppercase;
+ font-weight: 600;
+ }
+}
+
+.feed-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.submission-card {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ background: #2c2c2e;
+ border-radius: 4px;
+ overflow: hidden;
+ height: 44px;
+ flex-shrink: 0;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.3);
+
+ /* Status specific styles */
+ &.CORRECT { border-left: 3px solid #2ecc71; .badge { color: #2ecc71; } }
+ &.WRONG { border-left: 3px solid #e74c3c; .badge { color: #e74c3c; } }
+ &.INDETERMINATE { border-left: 3px solid #95a5a6; .badge { color: #95a5a6; } }
+
+ .preview-media {
+ width: 78px;
+ height: 100%;
+ background: #111;
+ flex-shrink: 0;
+
+ img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ .no-media {
+ color: #666;
+ font-size: 10px;
+ text-align: center;
+ line-height: 44px;
+ }
+ }
+
+ .preview-text {
+ width: 78px;
+ height: 100%;
+ background: #111;
+ color: #ddd;
+ font-style: italic;
+ font-size: 10px;
+ padding: 4px;
+ box-sizing: border-box;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex-shrink: 0;
+ }
+
+ .card-details {
+ flex: 1;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 10px;
+ overflow: hidden;
+
+ .team {
+ font-weight: bold;
+ color: #fff;
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .time {
+ color: #aaa;
+ font-family: monospace;
+ font-size: 11px;
+ margin-left: 8px;
+ flex-shrink: 0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/viewer/recent-submissions/recent-submissions.component.ts b/frontend/src/app/viewer/recent-submissions/recent-submissions.component.ts
new file mode 100644
index 000000000..c440ee78d
--- /dev/null
+++ b/frontend/src/app/viewer/recent-submissions/recent-submissions.component.ts
@@ -0,0 +1,119 @@
+import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core';
+import { Observable, combineLatest, of } from 'rxjs';
+import { catchError, map, switchMap, filter, sampleTime, shareReplay } from 'rxjs/operators';
+import { ApiEvaluationInfo, ApiEvaluationState, ApiMediaItem, EvaluationService } from '../../../../openapi';
+import { AppConfig } from '../../app.config';
+import { HttpClient } from '@angular/common/http';
+
+@Component({
+ selector: 'app-recent-submissions',
+ templateUrl: './recent-submissions.component.html',
+ styleUrls: ['./recent-submissions.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class RecentSubmissionsComponent implements OnInit {
+ @Input() info: Observable;
+ @Input() state: Observable;
+
+ recentSubmissions$: Observable;
+
+ private serverTimeOffset = 0;
+ private currentTaskId: string | null = null;
+ private currentTaskStartTime: number = 0;
+
+ constructor(
+ private evaluationService: EvaluationService,
+ private config: AppConfig,
+ private http: HttpClient
+ ) {}
+
+ ngOnInit(): void {
+ // Calculate the offset between server time and client time
+ this.http.get(this.config.resolveApiUrl('/client/evaluation/list'), { observe: 'response', responseType: 'text' }).subscribe(res => {
+ const serverDateStr = res.headers.get('Date');
+ if (serverDateStr) {
+ const serverTime = new Date(serverDateStr).getTime();
+ this.serverTimeOffset = Date.now() - serverTime;
+ }
+ });
+
+ const submissions$ = this.state.pipe(
+ sampleTime(2000),
+ switchMap(st => this.evaluationService.getApiV2EvaluationByEvaluationIdSubmissionList(st.evaluationId).pipe(
+ catchError(() => of([]))
+ )),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
+
+ this.recentSubmissions$ = combineLatest([this.info, submissions$, this.state]).pipe(
+ map(([info, submissions, state]) => {
+ if (!info || !info.teams || !submissions || !state) return [];
+
+ const isRunning = state.taskStatus === 'RUNNING';
+
+ if (isRunning) {
+ if (this.currentTaskId !== state.taskId) {
+ this.currentTaskId = state.taskId;
+ const syncedServerTime = Date.now() - this.serverTimeOffset;
+ this.currentTaskStartTime = syncedServerTime - (state.timeElapsed * 1000);
+ }
+ } else {
+ this.currentTaskId = null;
+ }
+
+ const teamMap = new Map();
+ info.teams.forEach(t => teamMap.set(t.id, t.name));
+
+ const feed: any[] = [];
+
+ submissions.forEach(sub => {
+ const teamName = teamMap.get(sub.teamId) || 'Unknown';
+
+ sub.answers.forEach((ans, index) => {
+ const firstAns = ans.answers && ans.answers.length > 0 ? ans.answers[0] : null;
+
+ let timeDisplay = '';
+
+ // Branch for different time display logic based on whether the task is still running or not
+ if (isRunning && this.currentTaskStartTime > 0) {
+ // If running, display relative time since task start
+ const relativeMs = Math.max(0, sub.timestamp - this.currentTaskStartTime);
+ const totalSeconds = Math.floor(relativeMs / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = (totalSeconds % 60).toString().padStart(2, '0');
+ timeDisplay = `${minutes}:${seconds}`;
+ } else {
+ // If ended, show absolute time
+ const d = new Date(sub.timestamp);
+ const hh = d.getHours().toString().padStart(2, '0');
+ const mm = d.getMinutes().toString().padStart(2, '0');
+ const ss = d.getSeconds().toString().padStart(2, '0');
+ timeDisplay = `${hh}:${mm}:${ss}`;
+ }
+
+ feed.push({
+ uniqueId: `${sub.submissionId}-${index}`,
+ teamName: teamName,
+ status: ans.status,
+ type: firstAns?.type,
+ previewUrl: this.previewOfItem(firstAns?.item, firstAns?.start),
+ previewText: firstAns?.text,
+ relativeTime: timeDisplay
+ });
+ });
+ });
+
+ return feed.reverse().slice(0, 30);
+ })
+ );
+ }
+
+ public previewOfItem(item: ApiMediaItem, start: number): string | null {
+ if (!item) return null;
+ return this.config.resolveApiUrl(`/preview/${item.mediaItemId}/${start == null ? 0 : start}`);
+ }
+
+ public trackByUniqueId(index: number, sub: any): string {
+ return sub.uniqueId;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/viewer/run-viewer.component.html b/frontend/src/app/viewer/run-viewer.component.html
index e1738a3b7..97a871726 100644
--- a/frontend/src/app/viewer/run-viewer.component.html
+++ b/frontend/src/app/viewer/run-viewer.component.html
@@ -11,11 +11,25 @@
-