2 Commits
Author SHA1 Message Date
FrankandClaude Sonnet 5 a9cb0fa57f Turn admin lobby chat panel into a tab
The lobby chat was a standalone card sitting above the per-player log
tabs. Folds it into the same tab bar as the first (default-active)
tab instead, so the session log view has one consistent tab strip.
The tab-switching script now toggles by an .admin-tab-panel class
instead of assuming every panel's id starts with "player-", since
that's no longer true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 16:48:06 +02:00
FrankandClaude Sonnet 5 2bfc2aca1a Keep pregame lobby chat open for an hour after the game ends
The chat used to disappear the moment a session left CREATED status.
Sessions now record a finishedAt timestamp when they're won or lost,
and the lobby (with chat) stays reachable via /game/{session} for an
hour afterward instead of immediately redirecting to the win/lose
feedback page. The lobby template shows a distinct "game finished"
header with a link to that feedback page during this window.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 16:47:57 +02:00
10 changed files with 187 additions and 83 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const activate = (id) => { const activate = (id) => {
document.querySelectorAll('[id^="player-"]').forEach(el => el.style.display = 'none'); document.querySelectorAll('.admin-tab-panel').forEach(el => el.style.display = 'none');
const target = document.getElementById(id); const target = document.getElementById(id);
if (target) { if (target) {
target.style.display = 'block'; target.style.display = 'block';
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260810130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add finished_at column to session table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE session ADD finished_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE session DROP finished_at');
}
}
@@ -35,6 +35,7 @@ final class GameAdminSessionController extends AbstractController
} }
$session->setStatus(SessionStatus::LOST); $session->setStatus(SessionStatus::LOST);
$session->setFinishedAt(new \DateTime());
$em->flush(); $em->flush();
$this->addFlash('success', sprintf('Session #%d closed.', $session->getId())); $this->addFlash('success', sprintf('Session #%d closed.', $session->getId()));
@@ -43,6 +43,7 @@ final class GameApiController extends AbstractController
if ($session->getStatus() === SessionStatus::PLAYING) { if ($session->getStatus() === SessionStatus::PLAYING) {
if ($session->getTimer() !== null && $now >= $session->getTimer()) { if ($session->getTimer() !== null && $now >= $session->getTimer()) {
$session->setStatus(SessionStatus::LOST); $session->setStatus(SessionStatus::LOST);
$session->setFinishedAt(new \DateTime());
$this->entityManager->persist($session); $this->entityManager->persist($session);
$this->entityManager->flush(); $this->entityManager->flush();
$isFinished = true; $isFinished = true;
+1 -1
View File
@@ -149,7 +149,7 @@ final class GameController extends AbstractController
// Lazily pick up readiness changes from other players since our last request // Lazily pick up readiness changes from other players since our last request
$dashboardService->checkAllPlayersReady($session); $dashboardService->checkAllPlayersReady($session);
if ($session->getStatus() === SessionStatus::CREATED) { if ($dashboardService->isLobbyChatOpen($session)) {
return $this->render('game/lobby.html.twig', [ return $this->render('game/lobby.html.twig', [
'session' => $session, 'session' => $session,
'messages' => $dashboardService->getLobbyMessages($session), 'messages' => $dashboardService->getLobbyMessages($session),
+15
View File
@@ -31,6 +31,9 @@ class Session
#[ORM\Column(type: Types::DATETIME_MUTABLE)] #[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $created = null; private ?\DateTimeInterface $created = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $finishedAt = null;
#[ORM\OneToMany(mappedBy: 'session', targetEntity: Player::class)] #[ORM\OneToMany(mappedBy: 'session', targetEntity: Player::class)]
private Collection $players; private Collection $players;
@@ -97,6 +100,18 @@ class Session
return $this; return $this;
} }
public function getFinishedAt(): ?\DateTimeInterface
{
return $this->finishedAt;
}
public function setFinishedAt(?\DateTimeInterface $finishedAt): static
{
$this->finishedAt = $finishedAt;
return $this;
}
/** /**
* @return Collection<int, Player> * @return Collection<int, Player>
*/ */
+25 -1
View File
@@ -24,6 +24,7 @@ final class GameDashboardService
{ {
private const READY_TIMEOUT_SECONDS = 60; private const READY_TIMEOUT_SECONDS = 60;
private const LOBBY_MESSAGE_MAX_LENGTH = 500; private const LOBBY_MESSAGE_MAX_LENGTH = 500;
private const LOBBY_CHAT_GRACE_PERIOD_SECONDS = 3600;
public function __construct( public function __construct(
private readonly GameRepository $gameRepository, private readonly GameRepository $gameRepository,
@@ -302,9 +303,32 @@ final class GameDashboardService
return $this->lobbyMessageRepository->findForSession($session); return $this->lobbyMessageRepository->findForSession($session);
} }
/**
* The lobby chat is open while a session is still gathering players, and stays
* open for a grace period after the game ends so players can wrap up the
* conversation before it disappears.
*/
public function isLobbyChatOpen(Session $session): bool
{
if ($session->getStatus() === SessionStatus::CREATED) {
return true;
}
if (!in_array($session->getStatus(), [SessionStatus::WON, SessionStatus::LOST], true)) {
return false;
}
$finishedAt = $session->getFinishedAt();
if ($finishedAt === null) {
return false;
}
return (new \DateTime())->getTimestamp() - $finishedAt->getTimestamp() < self::LOBBY_CHAT_GRACE_PERIOD_SECONDS;
}
public function postLobbyMessage(Session $session, User $user, string $content): ?LobbyMessage public function postLobbyMessage(Session $session, User $user, string $content): ?LobbyMessage
{ {
if ($session->getStatus() !== SessionStatus::CREATED) { if (!$this->isLobbyChatOpen($session)) {
return null; return null;
} }
+1
View File
@@ -1118,6 +1118,7 @@ class GameResponseService
} }
$session->setStatus(SessionStatus::WON); $session->setStatus(SessionStatus::WON);
$session->setFinishedAt(new \DateTime());
$this->entityManager->persist($session); $this->entityManager->persist($session);
$this->entityManager->flush(); $this->entityManager->flush();
+74 -56
View File
@@ -10,9 +10,58 @@
<h1 style="margin: 0 0 0.25rem; font-size: 1.5rem; color: #0f172a;">{{ session.game.name }} — Session #{{ session.id }}</h1> <h1 style="margin: 0 0 0.25rem; font-size: 1.5rem; color: #0f172a;">{{ session.game.name }} — Session #{{ session.id }}</h1>
<p style="margin: 0 0 1.5rem; color: #64748b; font-size: 0.9rem;">{{ session.status.value }} · {{ session.players|length }} player(s) · Created {{ session.created|date('Y-m-d H:i') }}</p> <p style="margin: 0 0 1.5rem; color: #64748b; font-size: 0.9rem;">{{ session.status.value }} · {{ session.players|length }} player(s) · Created {{ session.created|date('Y-m-d H:i') }}</p>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.25rem; margin-bottom: 1.5rem;"> {# Tab buttons #}
<h2 style="margin: 0 0 1rem; font-size: 1.1rem; color: #0f172a;">Pregame lobby chat</h2> <div style="display: flex; gap: 0; margin-bottom: 0; border-bottom: 1px solid #e2e8f0; overflow-x: auto; -webkit-overflow-scrolling: touch;">
<button
data-tab-target="lobby-chat-tab"
id="tab-lobby-chat"
style="
flex-shrink: 0;
white-space: nowrap;
padding: 0.6rem 1.25rem;
border: 1px solid #3b82f6;
border-bottom: 1px solid #fff;
background: #fff;
color: #1e40af;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
border-radius: 6px 6px 0 0;
margin-bottom: -1px;
"
>Lobby Chat</button>
{% for playerLog in playersLogs %}
<button
data-tab-target="player-{{ loop.index }}"
id="tab-{{ loop.index }}"
style="
flex-shrink: 0;
white-space: nowrap;
padding: 0.6rem 1.25rem;
border: 1px solid #e2e8f0;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
color: #64748b;
font-size: 0.9rem;
font-weight: 400;
cursor: pointer;
border-radius: 6px 6px 0 0;
margin-bottom: -1px;
"
>{{ playerLog.username }}</button>
{% endfor %}
</div>
{# Tab content #}
<div id="lobby-chat-tab" class="admin-tab-panel" style="
display: block;
background: #fff;
border: 1px solid #e2e8f0;
border-top: none;
border-radius: 0 0 8px 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
">
{% if lobbyMessages is empty %} {% if lobbyMessages is empty %}
<p style="margin: 0; color: #94a3b8;">No lobby chat messages for this session.</p> <p style="margin: 0; color: #94a3b8;">No lobby chat messages for this session.</p>
{% else %} {% else %}
@@ -28,59 +77,28 @@
{% endif %} {% endif %}
</div> </div>
{% if playersLogs is empty %} {% for playerLog in playersLogs %}
<div style="background: #fff; border-radius: 8px; padding: 2rem; text-align: center; color: #94a3b8; box-shadow: 0 1px 3px rgba(0,0,0,.07);"> <div id="player-{{ loop.index }}" class="admin-tab-panel" style="
No players in this session. display: none;
background: #fff;
border: 1px solid #e2e8f0;
border-top: none;
border-radius: 0 0 8px 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
">
<pre style="
background: #1e293b;
color: #e2e8f0;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85rem;
line-height: 1.5;
margin: 0;
">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre>
</div> </div>
{% else %} {% endfor %}
{# Tab buttons #}
<div style="display: flex; gap: 0; margin-bottom: 0; border-bottom: 1px solid #e2e8f0; overflow-x: auto; -webkit-overflow-scrolling: touch;">
{% for playerLog in playersLogs %}
<button
data-tab-target="player-{{ loop.index }}"
id="tab-{{ loop.index }}"
style="
flex-shrink: 0;
white-space: nowrap;
padding: 0.6rem 1.25rem;
border: 1px solid {{ loop.first ? '#3b82f6' : '#e2e8f0' }};
border-bottom: {{ loop.first ? '1px solid #fff' : '1px solid #e2e8f0' }};
background: {{ loop.first ? '#fff' : '#f8fafc' }};
color: {{ loop.first ? '#1e40af' : '#64748b' }};
font-size: 0.9rem;
font-weight: {{ loop.first ? '600' : '400' }};
cursor: pointer;
border-radius: 6px 6px 0 0;
margin-bottom: -1px;
"
>{{ playerLog.username }}</button>
{% endfor %}
</div>
{# Tab content #}
{% for playerLog in playersLogs %}
<div id="player-{{ loop.index }}" style="
display: {{ loop.first ? 'block' : 'none' }};
background: #fff;
border: 1px solid #e2e8f0;
border-top: none;
border-radius: 0 0 8px 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
">
<pre style="
background: #1e293b;
color: #e2e8f0;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85rem;
line-height: 1.5;
margin: 0;
">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre>
</div>
{% endfor %}
{% endif %}
{% endblock %} {% endblock %}
+42 -24
View File
@@ -1,38 +1,56 @@
{% extends 'layout/site.html.twig' %} {% extends 'layout/site.html.twig' %}
{% block title %}Waiting for players - {{ session.game.name }}{% endblock %} {% set isCreated = session.status.value == 'created' %}
{% set isFinished = session.status.value in ['won', 'lost'] %}
{% block title %}{{ isCreated ? 'Waiting for players' : 'Post-game chat' }} - {{ session.game.name }}{% endblock %}
{% block body %} {% block body %}
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-8"> <div class="col-md-8">
<div class="card shadow-sm mb-4"> {% if isCreated %}
<div class="card-header bg-primary text-white"> <div class="card shadow-sm mb-4">
<h3 class="card-title mb-0">Waiting for more players to join</h3> <div class="card-header bg-primary text-white">
</div> <h3 class="card-title mb-0">Waiting for more players to join</h3>
<div class="card-body">
<h4>{{ session.game.name }}</h4>
<p>Share the invite code with your friends. Feel free to chat below while you wait &mdash; no need to reload the page.</p>
<div class="alert alert-info">
<strong>Players joined:</strong> {{ session.players|length }} / {{ session.game.numberOfPlayers }}
</div> </div>
<div class="card-body">
<h4>{{ session.game.name }}</h4>
<p>Share the invite code with your friends. Feel free to chat below while you wait &mdash; no need to reload the page.</p>
<ul class="list-group mb-3"> <div class="alert alert-info">
{% for sessionPlayer in session.players %} <strong>Players joined:</strong> {{ session.players|length }} / {{ session.game.numberOfPlayers }}
<li class="list-group-item">{{ sessionPlayer.user.username }}</li> </div>
{% endfor %}
</ul>
{% if session.players|length >= session.game.numberOfPlayers %} <ul class="list-group mb-3">
<form method="post" action="{{ path('game_dashboard') }}" class="mb-3"> {% for sessionPlayer in session.players %}
<input type="hidden" name="session_id" value="{{ session.id }}"> <li class="list-group-item">{{ sessionPlayer.user.username }}</li>
<button type="submit" name="start_session" class="btn btn-success">Start Session</button> {% endfor %}
</form> </ul>
{% endif %}
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary btn-sm">Back to Dashboard</a> {% if session.players|length >= session.game.numberOfPlayers %}
<form method="post" action="{{ path('game_dashboard') }}" class="mb-3">
<input type="hidden" name="session_id" value="{{ session.id }}">
<button type="submit" name="start_session" class="btn btn-success">Start Session</button>
</form>
{% endif %}
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary btn-sm">Back to Dashboard</a>
</div>
</div> </div>
</div> {% elseif isFinished %}
<div class="card shadow-sm mb-4">
<div class="card-header {{ session.status.value == 'won' ? 'bg-success' : 'bg-secondary' }} text-white">
<h3 class="card-title mb-0">{{ session.status.value == 'won' ? 'You won!' : 'Game over' }}</h3>
</div>
<div class="card-body">
<h4>{{ session.game.name }}</h4>
<p>The game has ended, but the chat is still open for a little while &mdash; feel free to keep talking.</p>
<a href="{{ path(session.status.value == 'won' ? 'game_won' : 'game_lost', {session: session.id}) }}" class="btn btn-primary btn-sm">View results</a>
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary btn-sm">Back to Dashboard</a>
</div>
</div>
{% endif %}
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header"> <div class="card-header">