From 2b3c90d3fc4d7ec65c90a3d3a144a620f6ab5b4d Mon Sep 17 00:00:00 2001
From: Frank
Date: Sat, 22 Aug 2026 18:41:47 +0200
Subject: [PATCH] Show feedback in the admin panel and as aggregate stats on
the briefing page
Feedback was only ever written to session_setting rows with no way to
view it short of querying the database directly. Adds a FeedbackService
that pulls per-player feedback entries and aggregate averages, backing
two new views:
- /admin/feedback: a full table of every submission (game, session,
player, ratings, comment) plus summary tiles, linked from the admin
sidebar.
- /briefing: a "Field Reports" block with the average difficulty/
entertainment/theme ratings, shown to prospective players. Free-text
comments are deliberately left off this public page since they're
unmoderated player input.
Co-Authored-By: Claude Sonnet 5
---
.../GameAdminFeedbackController.php | 25 ++++++
.../Repository/SessionSettingRepository.php | 18 ++++
src/Game/Service/FeedbackService.php | 84 +++++++++++++++++++
src/Website/Controller/HomeController.php | 8 +-
templates/game/admin/base.html.twig | 1 +
templates/game/admin/feedback/index.html.twig | 67 +++++++++++++++
templates/website/home/intro.html.twig | 39 +++++++++
7 files changed, 239 insertions(+), 3 deletions(-)
create mode 100644 src/Game/Controller/GameAdminFeedbackController.php
create mode 100644 src/Game/Service/FeedbackService.php
create mode 100644 templates/game/admin/feedback/index.html.twig
diff --git a/src/Game/Controller/GameAdminFeedbackController.php b/src/Game/Controller/GameAdminFeedbackController.php
new file mode 100644
index 0000000..976b7df
--- /dev/null
+++ b/src/Game/Controller/GameAdminFeedbackController.php
@@ -0,0 +1,25 @@
+render('game/admin/feedback/index.html.twig', [
+ 'entries' => $feedbackService->getFeedbackEntries(),
+ 'summary' => $feedbackService->getFeedbackSummary(),
+ ]);
+ }
+}
diff --git a/src/Game/Repository/SessionSettingRepository.php b/src/Game/Repository/SessionSettingRepository.php
index 45b788a..d1da111 100644
--- a/src/Game/Repository/SessionSettingRepository.php
+++ b/src/Game/Repository/SessionSettingRepository.php
@@ -36,4 +36,22 @@ class SessionSettingRepository extends ServiceEntityRepository
return $qb->getQuery()->getOneOrNullResult();
}
+
+ /**
+ * @return SessionSetting[]
+ */
+ public function findFeedbackSettings(): array
+ {
+ return $this->createQueryBuilder('s')
+ ->andWhere('s.name IN (:names)')
+ ->setParameter('names', [
+ SessionSettingType::FEEDBACK_DIFFICULTY->value,
+ SessionSettingType::FEEDBACK_ENTERTAINING->value,
+ SessionSettingType::FEEDBACK_THEME->value,
+ SessionSettingType::FEEDBACK_TEXT->value,
+ ])
+ ->orderBy('s.player', 'DESC')
+ ->getQuery()
+ ->getResult();
+ }
}
diff --git a/src/Game/Service/FeedbackService.php b/src/Game/Service/FeedbackService.php
new file mode 100644
index 0000000..f440986
--- /dev/null
+++ b/src/Game/Service/FeedbackService.php
@@ -0,0 +1,84 @@
+
+ */
+ public function getFeedbackEntries(): array
+ {
+ $entries = [];
+
+ foreach ($this->sessionSettingRepository->findFeedbackSettings() as $setting) {
+ $player = $setting->getPlayer();
+ if (!$player) {
+ continue;
+ }
+
+ $key = $player->getId();
+ if (!isset($entries[$key])) {
+ $entries[$key] = [
+ 'session' => $setting->getSession(),
+ 'player' => $player,
+ 'difficulty' => null,
+ 'entertaining' => null,
+ 'theme' => null,
+ 'text' => null,
+ ];
+ }
+
+ match ($setting->getName()) {
+ SessionSettingType::FEEDBACK_DIFFICULTY => $entries[$key]['difficulty'] = (int) $setting->getValue(),
+ SessionSettingType::FEEDBACK_ENTERTAINING => $entries[$key]['entertaining'] = (int) $setting->getValue(),
+ SessionSettingType::FEEDBACK_THEME => $entries[$key]['theme'] = (int) $setting->getValue(),
+ SessionSettingType::FEEDBACK_TEXT => $entries[$key]['text'] = $setting->getValue(),
+ default => null,
+ };
+ }
+
+ return array_values($entries);
+ }
+
+ /**
+ * Aggregate numbers only - free-text comments are left out since they're
+ * unmoderated player input and this summary is meant for the public
+ * briefing page. Use getFeedbackEntries() for the admin-only raw view.
+ *
+ * @return array{count: int, avgDifficulty: ?float, avgEntertaining: ?float, avgTheme: ?float}
+ */
+ public function getFeedbackSummary(): array
+ {
+ $entries = $this->getFeedbackEntries();
+
+ return [
+ 'count' => count($entries),
+ 'avgDifficulty' => $this->average(array_column($entries, 'difficulty')),
+ 'avgEntertaining' => $this->average(array_column($entries, 'entertaining')),
+ 'avgTheme' => $this->average(array_column($entries, 'theme')),
+ ];
+ }
+
+ /**
+ * @param array $values
+ */
+ private function average(array $values): ?float
+ {
+ $values = array_filter($values, static fn ($v) => $v !== null);
+
+ return $values ? round(array_sum($values) / count($values), 1) : null;
+ }
+}
diff --git a/src/Website/Controller/HomeController.php b/src/Website/Controller/HomeController.php
index 12b6ea5..41a481f 100644
--- a/src/Website/Controller/HomeController.php
+++ b/src/Website/Controller/HomeController.php
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace App\Website\Controller;
+use App\Game\Service\FeedbackService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -17,10 +18,11 @@ final class HomeController extends AbstractController
}
#[Route(path: '/briefing', name: 'website_intro')]
- public function intro(): Response
+ public function intro(FeedbackService $feedbackService): Response
{
- return $this->render(
- 'website/home/intro.html.twig');
+ return $this->render('website/home/intro.html.twig', [
+ 'feedbackSummary' => $feedbackService->getFeedbackSummary(),
+ ]);
}
#[Route(path: '/looking-for-help', name: 'website_help_wanted')]
diff --git a/templates/game/admin/base.html.twig b/templates/game/admin/base.html.twig
index 0bd26e7..5047459 100644
--- a/templates/game/admin/base.html.twig
+++ b/templates/game/admin/base.html.twig
@@ -101,6 +101,7 @@
{ route: 'game_admin_users', label: 'Users', icon: '👤' },
{ route: 'game_admin_games', label: 'Games', icon: '🎮' },
{ route: 'game_admin_sessions', label: 'Sessions', icon: 'â–¶' },
+ { route: 'game_admin_feedback', label: 'Feedback', icon: '💬' },
{ route: 'game_admin_email_log', label: 'Email Log', icon: '✉' },
] %}
diff --git a/templates/game/admin/feedback/index.html.twig b/templates/game/admin/feedback/index.html.twig
new file mode 100644
index 0000000..28be7ca
--- /dev/null
+++ b/templates/game/admin/feedback/index.html.twig
@@ -0,0 +1,67 @@
+{% extends 'game/admin/base.html.twig' %}
+
+{% block title %}Feedback — Admin{% endblock %}
+
+{% block admin_body %}
+
+
Feedback
+ {{ entries|length }} submission{{ entries|length != 1 ? 's' : '' }}
+
+
+
+ {% set stats = [
+ { label: 'Submissions', value: summary.count, color: '#64748b' },
+ { label: 'Avg. Difficulty', value: summary.avgDifficulty is not null ? summary.avgDifficulty ~ '/10' : '—', color: '#3b82f6' },
+ { label: 'Avg. Entertainment', value: summary.avgEntertaining is not null ? summary.avgEntertaining ~ '/10' : '—', color: '#8b5cf6' },
+ { label: 'Avg. Theme', value: summary.avgTheme is not null ? summary.avgTheme ~ '/10' : '—', color: '#10b981' },
+ ] %}
+
+ {% for stat in stats %}
+
+
{{ stat.value }}
+
{{ stat.label }}
+
+ {% endfor %}
+
+
+
+
+
+
+ | Game |
+ Session |
+ Player |
+ Difficulty |
+ Entertainment |
+ Theme |
+ Comment |
+
+
+
+ {% for entry in entries %}
+
+ | {{ entry.session.game.name }} |
+
+ #{{ entry.session.id }}
+ |
+ {{ entry.player.user.username }} |
+ {{ entry.difficulty ?? '—' }} |
+ {{ entry.entertaining ?? '—' }} |
+ {{ entry.theme ?? '—' }} |
+ {{ entry.text ?? '' }} |
+
+ {% else %}
+
+ | No feedback submitted yet. |
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/templates/website/home/intro.html.twig b/templates/website/home/intro.html.twig
index fc72133..7ae9acd 100644
--- a/templates/website/home/intro.html.twig
+++ b/templates/website/home/intro.html.twig
@@ -30,6 +30,17 @@
color: var(--brand-teal-light, #6fb8d1);
padding-right: .1em;
}
+ .briefing-feedback {
+ background: rgba(111, 184, 209, .08);
+ border: 1px solid #1c5a70;
+ border-radius: .5rem;
+ padding: 1.25rem;
+ }
+ .briefing-feedback-stat {
+ font-size: 1.75rem;
+ font-weight: 700;
+ color: #6fb8d1;
+ }
.dev-watermark-wrap {
position: relative;
display: inline-block;
@@ -101,6 +112,34 @@
+ {% if feedbackSummary.count > 0 %}
+
+
+ Field Reports from {{ feedbackSummary.count }} Agent{{ feedbackSummary.count != 1 ? 's' : '' }}
+
+
+ {% if feedbackSummary.avgDifficulty is not null %}
+
+
{{ feedbackSummary.avgDifficulty }}/10
+
Difficulty
+
+ {% endif %}
+ {% if feedbackSummary.avgEntertaining is not null %}
+
+
{{ feedbackSummary.avgEntertaining }}/10
+
Entertainment
+
+ {% endif %}
+ {% if feedbackSummary.avgTheme is not null %}
+
+
{{ feedbackSummary.avgTheme }}/10
+
Theme
+
+ {% endif %}
+
+
+ {% endif %}
+