Combat tracker: multiple named encounters

- Encounter entity; Combatant now belongs to an encounter (ON DELETE
  CASCADE). Migration adds encounter_id, rehoming any existing combatants.
- /tracker lists encounters (name, count, last updated) with a "New
  encounter" form; /tracker/{id} is the HP tracker for one encounter.
- Encounters can be renamed, have their combatants cleared, or be deleted.
- The Enemies list gains an encounter picker next to "Add to tracker"
  (existing encounter or "— New encounter —", which prompts for a name);
  an encounter's "Add enemies" link pre-selects it via ?encounter=.
- Routes for combatant HP/remove moved under /tracker/combatant/ to keep
  the numeric {id} for encounters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-06 19:24:37 +02:00
co-authored by Claude Sonnet 5
parent 9854e6c034
commit cbe4b0a0e1
11 changed files with 433 additions and 112 deletions
+22 -64
View File
@@ -4,77 +4,35 @@
{% block body %}
<div class="d-flex justify-content-between align-items-center mb-3 gap-2 flex-wrap">
<h1 class="h3 mb-0">Combat tracker
{% if combatants %}<span class="text-body-secondary fs-6">· {{ combatants|length }}</span>{% endif %}
</h1>
<div class="d-flex gap-2">
<a href="{{ path('app_enemies') }}" class="btn btn-outline-secondary btn-sm">Add from Enemies</a>
{% if combatants %}
<form method="post" action="{{ path('app_tracker_clear') }}"
onsubmit="return confirm('Clear the whole tracker?')">
<input type="hidden" name="_token" value="{{ csrf_token('tracker-op') }}">
<button class="btn btn-outline-danger btn-sm">Clear all</button>
</form>
{% endif %}
</div>
<h1 class="h3 mb-0">Encounters</h1>
<form method="post" action="{{ path('app_tracker_new') }}" class="d-flex gap-2">
<input type="hidden" name="_token" value="{{ csrf_token('tracker-new') }}">
<input type="text" name="name" class="form-control form-control-sm" style="width: 14rem;"
placeholder="New encounter name" aria-label="New encounter name">
<button class="btn btn-primary btn-sm text-nowrap">New encounter</button>
</form>
</div>
{% if not combatants %}
{% if not encounters %}
<div class="card shadow-sm">
<div class="card-body text-center text-body-secondary py-5">
No combatants yet. Go to <strong>Enemies</strong>, tick the ones you want (set a count for
multiples), and choose <strong>Add to tracker</strong>.
No encounters yet. Name one above, or go to <strong>Enemies</strong>, tick the combatants
you want and add them to a new encounter.
</div>
</div>
{% else %}
<div class="d-flex flex-column gap-2">
{% for c in combatants %}
{% set pct = c.hpPercent %}
{% set bar = c.isDown ? 'bg-secondary' : (pct <= 25 ? 'bg-danger' : (pct <= 50 ? 'bg-warning' : 'bg-success')) %}
<div class="card shadow-sm {{ c.isDown ? 'opacity-75' : '' }}">
<div class="card-body py-2">
<div class="row align-items-center g-2">
<div class="col-12 col-md-4">
<div class="fw-semibold {{ c.isDown ? 'text-decoration-line-through' : '' }}">
{{ c.label }}
{% if c.isDown %}<span class="badge text-bg-secondary ms-1">Down</span>{% endif %}
</div>
{% if c.enemy %}
<a href="{{ path('app_enemies_show', {id: c.enemy.id}) }}"
class="small text-decoration-none text-body-secondary">stat block</a>
{% endif %}
</div>
<div class="col-12 col-md-4">
<div class="d-flex align-items-center gap-2">
<span class="font-monospace" style="min-width: 5rem;">
<span class="fs-5 fw-semibold">{{ c.currentHp }}</span> / {{ c.maxHp }}
</span>
<div class="progress flex-grow-1" role="progressbar" style="height: 10px;">
<div class="progress-bar {{ bar }}" style="width: {{ pct }}%"></div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="d-flex align-items-center gap-1 justify-content-md-end flex-wrap">
<form method="post" action="{{ path('app_tracker_hp', {id: c.id}) }}"
class="d-flex align-items-center gap-1">
<input type="number" name="amount" min="0" inputmode="numeric"
class="form-control form-control-sm" style="width: 4.5rem;"
placeholder="HP" aria-label="Amount">
<button name="mode" value="damage" class="btn btn-sm btn-outline-danger">Damage</button>
<button name="mode" value="heal" class="btn btn-sm btn-outline-success">Heal</button>
</form>
<form method="post" action="{{ path('app_tracker_remove', {id: c.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('tracker-op') }}">
<button class="btn btn-sm btn-outline-secondary" title="Remove" aria-label="Remove">&times;</button>
</form>
</div>
</div>
</div>
</div>
</div>
<div class="list-group shadow-sm">
{% for e in encounters %}
<a href="{{ path('app_tracker_show', {id: e.id}) }}"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span>
<span class="fw-semibold">{{ e.name }}</span>
<span class="text-body-secondary small">
· {{ e.combatants|length }} combatant{{ e.combatants|length == 1 ? '' : 's' }}
</span>
</span>
<span class="text-body-secondary small">updated {{ e.updatedAt|date('Y-m-d H:i') }}</span>
</a>
{% endfor %}
</div>
{% endif %}
+93
View File
@@ -0,0 +1,93 @@
{% extends 'base.html.twig' %}
{% block title %}{{ encounter.name }} · Tracker · dmtools{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-start mb-3 gap-3 flex-wrap">
<div>
<a href="{{ path('app_tracker') }}" class="text-decoration-none small">&larr; All encounters</a>
<form method="post" action="{{ path('app_tracker_rename', {id: encounter.id}) }}"
class="d-flex align-items-center gap-2 mt-1">
<input type="hidden" name="_token" value="{{ csrf_token('tracker-op') }}">
<input type="text" name="name" value="{{ encounter.name }}"
class="form-control form-control-lg fw-semibold border-0 px-0" style="width: min(20rem, 70vw);"
aria-label="Encounter name">
<button class="btn btn-sm btn-outline-secondary">Rename</button>
</form>
</div>
<div class="d-flex gap-2 flex-wrap">
<a href="{{ path('app_enemies', {encounter: encounter.id}) }}" class="btn btn-outline-secondary btn-sm">Add enemies</a>
{% if encounter.combatants|length %}
<form method="post" action="{{ path('app_tracker_clear', {id: encounter.id}) }}"
onsubmit="return confirm('Remove all combatants from this encounter?')">
<input type="hidden" name="_token" value="{{ csrf_token('tracker-op') }}">
<button class="btn btn-outline-danger btn-sm">Clear combatants</button>
</form>
{% endif %}
<form method="post" action="{{ path('app_tracker_delete', {id: encounter.id}) }}"
onsubmit="return confirm('Delete the “{{ encounter.name|e('js') }}” encounter?')">
<input type="hidden" name="_token" value="{{ csrf_token('tracker-op') }}">
<button class="btn btn-outline-danger btn-sm">Delete encounter</button>
</form>
</div>
</div>
{% if not encounter.combatants|length %}
<div class="card shadow-sm">
<div class="card-body text-center text-body-secondary py-5">
No combatants. Use <strong>Add enemies</strong> to pick some from the list.
</div>
</div>
{% else %}
<div class="d-flex flex-column gap-2">
{% for c in encounter.combatants %}
{% set pct = c.hpPercent %}
{% set bar = c.isDown ? 'bg-secondary' : (pct <= 25 ? 'bg-danger' : (pct <= 50 ? 'bg-warning' : 'bg-success')) %}
<div class="card shadow-sm {{ c.isDown ? 'opacity-75' : '' }}">
<div class="card-body py-2">
<div class="row align-items-center g-2">
<div class="col-12 col-md-4">
<div class="fw-semibold {{ c.isDown ? 'text-decoration-line-through' : '' }}">
{{ c.label }}
{% if c.isDown %}<span class="badge text-bg-secondary ms-1">Down</span>{% endif %}
</div>
{% if c.enemy %}
<a href="{{ path('app_enemies_show', {id: c.enemy.id}) }}"
class="small text-decoration-none text-body-secondary">stat block</a>
{% endif %}
</div>
<div class="col-12 col-md-4">
<div class="d-flex align-items-center gap-2">
<span class="font-monospace" style="min-width: 5rem;">
<span class="fs-5 fw-semibold">{{ c.currentHp }}</span> / {{ c.maxHp }}
</span>
<div class="progress flex-grow-1" role="progressbar" style="height: 10px;">
<div class="progress-bar {{ bar }}" style="width: {{ pct }}%"></div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="d-flex align-items-center gap-1 justify-content-md-end flex-wrap">
<form method="post" action="{{ path('app_tracker_hp', {id: c.id}) }}"
class="d-flex align-items-center gap-1">
<input type="number" name="amount" min="0" inputmode="numeric"
class="form-control form-control-sm" style="width: 4.5rem;"
placeholder="HP" aria-label="Amount">
<button name="mode" value="damage" class="btn btn-sm btn-outline-danger">Damage</button>
<button name="mode" value="heal" class="btn btn-sm btn-outline-success">Heal</button>
</form>
<form method="post" action="{{ path('app_tracker_combatant_remove', {id: c.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('tracker-op') }}">
<button class="btn btn-sm btn-outline-secondary" title="Remove" aria-label="Remove">&times;</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}