enhanci #9

Merged
lubiana merged 6 commits from enhanci into main 2025-06-28 20:26:12 +00:00
13 changed files with 311 additions and 48 deletions

View file

@ -13,8 +13,10 @@ import './styles/emoji-footprint.css';
import './javascript/theme.js'; import './javascript/theme.js';
import './javascript/emoji-footprint.js'; import './javascript/emoji-footprint.js';
import './javascript/modes.js'; import './javascript/modes.js';
import htmx from 'htmx.org';
import { initNumberInputs } from './javascript/numberInputs.js'; import { initNumberInputs } from './javascript/numberInputs.js';
import { initRadioState } from './javascript/radioState.js'; import { initRadioState } from './javascript/radioState.js';
window.htmx = htmx;
// Initialize everything when DOM is ready // Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {

View file

@ -37,4 +37,7 @@ return [
'chart.js/auto' => [ 'chart.js/auto' => [
'version' => '4.5.0', 'version' => '4.5.0',
], ],
'htmx.org' => [
'version' => '2.0.6',
],
]; ];

View file

@ -110,4 +110,19 @@ final class DrinkTypeController extends AbstractController
return $this->redirectToRoute('app_drink_type_index', [], Response::HTTP_SEE_OTHER); return $this->redirectToRoute('app_drink_type_index', [], Response::HTTP_SEE_OTHER);
} }
#[Route(path: '/{id}/history-chart', name: 'app_drink_type_history_chart', methods: ['GET'])]
public function historyChart(
DrinkType $drinkType,
GetStockHistory $getStockHistory,
GetWantedHistory $getWantedHistory,
): Response {
return $this->render('components/history_chart_with_dismiss.html.twig', [
'stock_history' => $getStockHistory($drinkType),
'wanted_history' => $getWantedHistory($drinkType),
'chart_id' => 'stockHistoryChart_' . $drinkType->getId(),
'title' => $drinkType->getName() . ' History',
'drink_type_id' => $drinkType->getId(),
]);
}
} }

View file

@ -23,10 +23,18 @@ final readonly class GetStockHistory
if ($drinkType->getId() === null) { if ($drinkType->getId() === null) {
return[]; return[];
} }
return $this->propertyChangeLogRepository->findBy([ $items = $this->propertyChangeLogRepository->findBy([
'entityClass' => DrinkType::class, 'entityClass' => DrinkType::class,
'propertyName' => 'currentStock', 'propertyName' => 'currentStock',
'entityId' => $drinkType->getId(), 'entityId' => $drinkType->getId(),
]); ]);
$currentStock = new PropertyChangeLog();
$currentStock->setNewValue((string) $drinkType->getCurrentStock());
$currentStock->setEntityClass(DrinkType::class);
$currentStock->setEntityId($drinkType->getId());
$currentStock->setPropertyName('currentStock');
$items[] = $currentStock;
return $items;
} }
} }

View file

@ -18,10 +18,17 @@ final readonly class GetWantedHistory
return[]; return[];
} }
return $this->propertyChangeLogRepository->findBy([ $items = $this->propertyChangeLogRepository->findBy([
'entityClass' => DrinkType::class, 'entityClass' => DrinkType::class,
'propertyName' => 'wantedStock', 'propertyName' => 'wantedStock',
'entityId' => $drinkType->getId(), 'entityId' => $drinkType->getId(),
]); ]);
$wantedStock = new PropertyChangeLog();
$wantedStock->setNewValue((string) $drinkType->getWantedStock());
$wantedStock->setEntityClass(DrinkType::class);
$wantedStock->setEntityId($drinkType->getId());
$wantedStock->setPropertyName('wantedStock');
$items[] = $wantedStock;
return $items;
} }
} }

View file

@ -17,7 +17,7 @@
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-lg sticky-top"> <nav class="navbar navbar-expand-lg sticky-top bg-primary">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="/"> <a class="navbar-brand" href="/">
{{ appName }} {{ appName }}
@ -51,6 +51,14 @@
<span class="emoji-bonkers">😈🍆💦👅💋🏳️‍🌈✨</span> <span class="emoji-bonkers">😈🍆💦👅💋🏳️‍🌈✨</span>
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{{ path('app_order_index') }}">
Orders
<span class="emoji-normal">🛒</span>
<span class="emoji-enhanced">🛒💰</span>
<span class="emoji-bonkers">🛒💰✨</span>
</a>
</li>
</ul> </ul>
<div class="btn-group ms-auto" role="group" aria-label="Mode selection"> <div class="btn-group ms-auto" role="group" aria-label="Mode selection">
<input type="radio" class="btn-check" name="mode" id="normal" autocomplete="off" checked> <input type="radio" class="btn-check" name="mode" id="normal" autocomplete="off" checked>
@ -80,7 +88,7 @@
</div> </div>
</div> </div>
</nav> </nav>
<div class="container mt-4"> <div class="container my-4">
{% block body %}{% endblock %} {% block body %}{% endblock %}
</div> </div>

View file

@ -0,0 +1,123 @@
{#
History Chart Component
Parameters:
- stock_history: Array of stock history records with changeDate and newValue properties
- wanted_history: Array of wanted stock history records with newValue properties
- chart_id: ID for the canvas element (default: 'stockHistoryChart')
- title: Title for the chart card (default: 'History')
#}
{% set chart_id = chart_id|default('stockHistoryChart') %}
{% set title = title|default('History') %}
<div class="card">
<div class="card-header">
<h5 class="card-title">{{ title }}</h5>
</div>
<div class="card-body">
<div style="height: 300px; position: relative;">
<canvas id="{{ chart_id }}"></canvas>
</div>
<script type="module">
import { Chart } from 'chart.js/auto';
// Create a unified set of dates from both datasets
{% set all_dates = [] %}
{% for record in stock_history %}
{% set date_str = record.changeDate|date('Y-m-d H:i') %}
{% if date_str not in all_dates %}
{% set all_dates = all_dates|merge([date_str]) %}
{% endif %}
{% endfor %}
{% for record in wanted_history %}
{% set date_str = record.changeDate|date('Y-m-d H:i') %}
{% if date_str not in all_dates %}
{% set all_dates = all_dates|merge([date_str]) %}
{% endif %}
{% endfor %}
// Sort dates chronologically
{% set all_dates = all_dates|sort %}
// Create date-indexed maps for both datasets
{% set stock_map = {} %}
{% for record in stock_history %}
{% set date_str = record.changeDate|date('Y-m-d H:i') %}
{% set stock_map = stock_map|merge({(date_str): record.newValue}) %}
{% endfor %}
{% set wanted_map = {} %}
{% for record in wanted_history %}
{% set date_str = record.changeDate|date('Y-m-d H:i') %}
{% set wanted_map = wanted_map|merge({(date_str): record.newValue}) %}
{% endfor %}
// Prepare data arrays with values aligned to dates
{% set stock_data = [] %}
{% set wanted_data = [] %}
{% for date in all_dates %}
{% if date in stock_map|keys %}
{% set stock_data = stock_data|merge([stock_map[date]]) %}
{% else %}
{% set stock_data = stock_data|merge(['null']) %}
{% endif %}
{% if date in wanted_map|keys %}
{% set wanted_data = wanted_data|merge([wanted_map[date]]) %}
{% else %}
{% set wanted_data = wanted_data|merge(['null']) %}
{% endif %}
{% endfor %}
new Chart(document.getElementById('{{ chart_id }}'), {
type: 'line',
data: {
labels: [{% for date in all_dates %}'{{ date }}'{{ not loop.last ? ',' }}{% endfor %}],
datasets: [{
label: 'Stock History',
data: [{{ stock_data|join(',') }}],
borderColor: 'rgb(75, 192, 192)',
tension: 0.1,
spanGaps: true
},
{
label: 'Wanted Stock History',
data: [{{ wanted_data|join(',') }}],
borderColor: 'rgb(255, 99, 132)',
tension: 0.1,
spanGaps: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: {
boxWidth: 12,
padding: 10
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
maxTicksLimit: 6
}
},
x: {
ticks: {
maxTicksLimit: 8,
maxRotation: 45
}
}
}
}
});
</script>
</div>
</div>

View file

@ -0,0 +1,118 @@
<tr id="history-row-{{ drink_type_id }}">
<td colspan="3">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">{{ title }}</h5>
<button type="button" class="btn btn-sm btn-danger"
onclick="document.getElementById('history-row-{{ drink_type_id }}').style.display = 'none';">
❌ Dismiss
</button>
</div>
<div class="card-body">
<div style="height: 300px; position: relative;">
<canvas id="{{ chart_id }}"></canvas>
</div>
<script type="module">
import { Chart } from 'chart.js/auto';
// Create a unified set of dates from both datasets
{% set all_dates = [] %}
{% for record in stock_history %}
{% set date_str = record.changeDate|date('Y-m-d H:i') %}
{% if date_str not in all_dates %}
{% set all_dates = all_dates|merge([date_str]) %}
{% endif %}
{% endfor %}
{% for record in wanted_history %}
{% set date_str = record.changeDate|date('Y-m-d H:i') %}
{% if date_str not in all_dates %}
{% set all_dates = all_dates|merge([date_str]) %}
{% endif %}
{% endfor %}
// Sort dates chronologically
{% set all_dates = all_dates|sort %}
// Create date-indexed maps for both datasets
{% set stock_map = {} %}
{% for record in stock_history %}
{% set date_str = record.changeDate|date('Y-m-d H:i') %}
{% set stock_map = stock_map|merge({(date_str): record.newValue}) %}
{% endfor %}
{% set wanted_map = {} %}
{% for record in wanted_history %}
{% set date_str = record.changeDate|date('Y-m-d H:i') %}
{% set wanted_map = wanted_map|merge({(date_str): record.newValue}) %}
{% endfor %}
// Prepare data arrays with values aligned to dates
{% set stock_data = [] %}
{% set wanted_data = [] %}
{% for date in all_dates %}
{% if date in stock_map|keys %}
{% set stock_data = stock_data|merge([stock_map[date]]) %}
{% else %}
{% set stock_data = stock_data|merge(['null']) %}
{% endif %}
{% if date in wanted_map|keys %}
{% set wanted_data = wanted_data|merge([wanted_map[date]]) %}
{% else %}
{% set wanted_data = wanted_data|merge(['null']) %}
{% endif %}
{% endfor %}
new Chart(document.getElementById('{{ chart_id }}'), {
type: 'line',
data: {
labels: [{% for date in all_dates %}'{{ date }}'{{ not loop.last ? ',' }}{% endfor %}],
datasets: [{
label: 'Stock History',
data: [{{ stock_data|join(',') }}],
borderColor: 'rgb(75, 192, 192)',
tension: 0.1,
spanGaps: true
},
{
label: 'Wanted Stock History',
data: [{{ wanted_data|join(',') }}],
borderColor: 'rgb(255, 99, 132)',
tension: 0.1,
spanGaps: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: {
boxWidth: 12,
padding: 10
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
maxTicksLimit: 6
}
},
x: {
ticks: {
maxTicksLimit: 8,
maxRotation: 45
}
}
}
}
});
</script>
</div>
</div>
</td>
</tr>

View file

@ -25,6 +25,7 @@
<thead> <thead>
<tr> <tr>
<th>🍻🥃 DRINK TYPE!!! 🍷🍸</th> <th>🍻🥃 DRINK TYPE!!! 🍷🍸</th>
<th>📚🔍 HISTORY!!! 📊📈</th>
<th>🎯💰 WANTED STOCK!!! ⭐✨</th> <th>🎯💰 WANTED STOCK!!! ⭐✨</th>
</tr> </tr>
</thead> </thead>
@ -36,10 +37,19 @@
{{ form_widget(drinkTypeForm.name) }} {{ form_widget(drinkTypeForm.name) }}
</td> </td>
<td> <td>
{{ form_widget(drinkTypeForm.wantedStock) }} <button class="btn btn-sm btn-info"
hx-get="{{ path('app_drink_type_history_chart', {'id': drinkTypeForm.vars.value.id}) }}"
hx-target="#history-row-{{ drinkTypeForm.vars.value.id }}"
hx-swap="outerHTML"
type="button"
>📊 Show History</button>
</td>
<td>
{{ form_widget(drinkTypeForm.wantedStock) }}
{{ form_errors(drinkTypeForm.wantedStock) }} {{ form_errors(drinkTypeForm.wantedStock) }}
</td> </td>
</tr> </tr>
<tr id="history-row-{{ drinkTypeForm.vars.value.id }}" style="display: none;" class="history-row"></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -62,43 +62,12 @@
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-12"> <div class="col-md-12">
<div class="card"> {% include 'components/history_chart.html.twig' with {
<div class="card-header"> 'stock_history': stock_history,
<h5 class="card-title">Example Chart</h5> 'wanted_history': wanted_history,
</div> 'chart_id': 'stockHistoryChart',
<div class="card-body"> 'title': 'History'
<canvas id="stockHistoryChart"></canvas> } %}
<script type="module">
import { Chart } from 'chart.js/auto';
new Chart(document.getElementById('stockHistoryChart'), {
type: 'line',
data: {
labels: [{% for record in stock_history %}'{{ record.changeDate|date('Y-m-d H:i') }}'{{ not loop.last ? ',' }}{% endfor %}],
datasets: [{
label: 'Stock History',
data: [{% for record in stock_history %}{{ record.newValue }}{{ not loop.last ? ',' }}{% endfor %}],
borderColor: 'rgb(75, 192, 192)',
tension: 0.1
},
{
label: 'Wanted Stock History',
data: [{% for record in wanted_history %}{{ record.newValue }}{{ not loop.last ? ',' }}{% endfor %}],
borderColor: 'rgb(255, 99, 132)',
tension: 0.1
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
</script>
</div>
</div>
</div> </div>
</div> </div>

View file

@ -12,11 +12,11 @@
<div class="number-input-wrapper"> <div class="number-input-wrapper">
<div class="input-group"> <div class="input-group">
<button class="btn btn-outline-secondary number-decrease" type="button" data-action="decrease"> <button class="btn btn-outline-secondary number-decrease" type="button" data-action="decrease">
➖🔥 DECREASE!!! 💥⚡ ➖🔥 DECREASE!!! 💥⚡📉
</button> </button>
{{- block('form_widget_simple') -}} {{- block('form_widget_simple') -}}
<button class="btn btn-outline-secondary number-increase" type="button" data-action="increase"> <button class="btn btn-outline-secondary number-increase" type="button" data-action="increase">
➕✨ INCREASE!!! 🚀💫 ➕✨ INCREASE!!! 🚀💫📈
</button> </button>
</div> </div>
</div> </div>

View file

@ -43,7 +43,7 @@ test('it returns stock history for a drink type', function (): void {
// Verify the result // Verify the result
expect($result)->toBeArray(); expect($result)->toBeArray();
expect($result)->toHaveCount(3); expect($result)->toHaveCount(4);
// Verify that all logs are for currentStock // Verify that all logs are for currentStock
foreach ($result as $log) { foreach ($result as $log) {
@ -81,7 +81,7 @@ test('it only returns logs for the specified drink type', function (): void {
$result = $getStockHistory($drinkType1); $result = $getStockHistory($drinkType1);
// Verify we only get logs for the first drink type // Verify we only get logs for the first drink type
expect($result)->toHaveCount(1); expect($result)->toHaveCount(2);
expect($result[0]->getEntityId())->toBe($drinkType1->getId()); expect($result[0]->getEntityId())->toBe($drinkType1->getId());
expect($result[0]->getNewValue())->toBe('10'); expect($result[0]->getNewValue())->toBe('10');
}); });

View file

@ -43,7 +43,7 @@ test('it returns wanted stock history for a drink type', function (): void {
// Verify the result // Verify the result
expect($result)->toBeArray(); expect($result)->toBeArray();
expect($result)->toHaveCount(3); expect($result)->toHaveCount(4);
// Verify that all logs are for wantedStock // Verify that all logs are for wantedStock
foreach ($result as $log) { foreach ($result as $log) {
@ -81,7 +81,7 @@ test('it only returns logs for the specified drink type', function (): void {
$result = $getWantedHistory($drinkType1); $result = $getWantedHistory($drinkType1);
// Verify we only get logs for the first drink type // Verify we only get logs for the first drink type
expect($result)->toHaveCount(1); expect($result)->toHaveCount(2);
expect($result[0]->getEntityId())->toBe($drinkType1->getId()); expect($result[0]->getEntityId())->toBe($drinkType1->getId());
expect($result[0]->getNewValue())->toBe('10'); expect($result[0]->getNewValue())->toBe('10');
}); });