db7094fd1e
CSRF / actions: - List action forms now send both REQUEST_TOKEN (Contao gate) and _token (Symfony, validated via isCsrfTokenValid) — mirrors the editor; fixes the 400/500 "Ungültiger CSRF-Token" on publish/toggle/duplicate/delete Public reader / URLs: - Reader & results read auto_item via Input::get (marks the route param used) so clean path URLs /<page>/<alias> no longer 404 - List controller generates path URLs via ['parameters' => '/'.$alias] - Allow re-participation in the same session after a 60s cooldown Review fixes: - Results: merge answer snapshots by questionId+type (no more split cards on label edits); seed "Vielleicht" when allowMaybe so screen matches Excel - Reader: replace generic answer hint with per-option "weiter zu Frage X" only when a jump is configured; strip HTML tags from survey description - PDF: render free-text frequency summary; drop duplicate "Minimum" label - Drag-sort: bundle SortableJS locally, drop the duplicated inline sort CSS/JS from _survey_assets, throttle the backend MutationObserver - Backend: move hardcoded tl_survey operation labels to TL_LANG (de+en) - Remove dead code: SurveyEditorVoter, SurveyPermissionService, categoryFilter/categorySummary + helpers, orphaned publishSurvey lang key Reader layout: - max-width 1000px (other views keep 1400px); back/next buttons fill the width as equal, card-sized halves; sort handle icon offset tweak Docs: update AI_HANDOVER (dual-token CSRF, clean URLs) and README Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
473 lines
17 KiB
PHP
473 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Mummert\SurveyBundle\Service;
|
|
|
|
use Mummert\SurveyBundle\Model\SurveyContentModel;
|
|
use Mummert\SurveyBundle\Model\SurveyModel;
|
|
use Mummert\SurveyBundle\Repository\SurveyAnswerRepository;
|
|
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
|
|
|
|
final class SurveyResultsViewService
|
|
{
|
|
public function __construct(
|
|
private readonly SurveyQuestionRepository $surveyQuestionRepository,
|
|
private readonly SurveyAnswerRepository $surveyAnswerRepository,
|
|
private readonly SurveySubmissionService $surveySubmissionService,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @return array{completedSubmissionCount:int,questionResults:list<array<string, mixed>>}
|
|
*/
|
|
public function buildForSurvey(SurveyModel $survey): array
|
|
{
|
|
$overview = $this->surveySubmissionService->getSubmissionOverview($survey);
|
|
$finishedSubmissions = array_values(array_filter(
|
|
$overview,
|
|
static fn (array $submission): bool => '1' === (string) ($submission['isFinished'] ?? '')
|
|
));
|
|
|
|
$completedSubmissionCount = count($finishedSubmissions);
|
|
$questions = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id);
|
|
$answers = $this->surveyAnswerRepository->findFinishedAnswersBySurvey((int) $survey->id);
|
|
|
|
return [
|
|
'completedSubmissionCount' => $completedSubmissionCount,
|
|
'questionResults' => $this->buildQuestionResults($questions, $answers, $completedSubmissionCount),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<SurveyContentModel> $questions
|
|
* @param list<array{questionId:int,questionType:string,questionLabel:string,questionDescription:string,questionSorting:int,value:string}> $answers
|
|
*
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function buildQuestionResults(array $questions, array $answers, int $completedSubmissionCount): array
|
|
{
|
|
$results = [];
|
|
|
|
foreach ($questions as $question) {
|
|
$key = $this->buildQuestionResultKey((int) $question->id, (string) $question->type);
|
|
|
|
$results[$key] = $this->buildResultSkeleton(
|
|
(int) $question->id,
|
|
(string) $question->type,
|
|
(string) $question->question,
|
|
(string) ($question->description ?? ''),
|
|
(int) ($question->sorting ?? 0),
|
|
);
|
|
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
|
|
$results[$key]['_allowMaybe'] = '1' === (string) $question->allowMaybe;
|
|
}
|
|
|
|
foreach ($answers as $answer) {
|
|
$questionId = (int) ($answer['questionId'] ?? 0);
|
|
$questionType = (string) ($answer['questionType'] ?? 'text');
|
|
$questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId;
|
|
$questionDescription = (string) ($answer['questionDescription'] ?? '');
|
|
$key = $this->buildQuestionResultKey($questionId, $questionType);
|
|
|
|
if (!isset($results[$key])) {
|
|
$results[$key] = $this->buildResultSkeleton(
|
|
$questionId,
|
|
$questionType,
|
|
$questionLabel,
|
|
$questionDescription,
|
|
(int) ($answer['questionSorting'] ?? 0),
|
|
);
|
|
}
|
|
|
|
$results[$key]['_values'][] = (string) ($answer['value'] ?? '');
|
|
}
|
|
|
|
foreach ($results as $key => $result) {
|
|
$results[$key] = $this->finalizeQuestionResult($result, $completedSubmissionCount);
|
|
}
|
|
|
|
uasort($results, static function (array $left, array $right): int {
|
|
$sortingComparison = ((int) ($left['_sorting'] ?? 0)) <=> ((int) ($right['_sorting'] ?? 0));
|
|
|
|
if (0 !== $sortingComparison) {
|
|
return $sortingComparison;
|
|
}
|
|
|
|
return strcasecmp((string) ($left['question'] ?? ''), (string) ($right['question'] ?? ''));
|
|
});
|
|
|
|
return array_values($results);
|
|
}
|
|
|
|
private function buildQuestionResultKey(int $questionId, string $questionType): string
|
|
{
|
|
// Bewusst nur Frage-ID + Typ: So werden Antwort-Snapshots und die aktuelle
|
|
// Frage auch dann zusammengeführt, wenn das Label nachträglich geändert wurde.
|
|
return implode('|', [$questionId, $questionType]);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildResultSkeleton(int $questionId, string $questionType, string $questionLabel, string $questionDescription, int $sorting): array
|
|
{
|
|
return [
|
|
'id' => $questionId,
|
|
'question' => $questionLabel,
|
|
'description' => $questionDescription,
|
|
'type' => $questionType,
|
|
'totalAnswers' => 0,
|
|
'responseRate' => 0,
|
|
'chart' => null,
|
|
'_sorting' => $sorting,
|
|
'_values' => [],
|
|
'_choiceOptions' => [],
|
|
'_allowMaybe' => false,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $result
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function finalizeQuestionResult(array $result, int $completedSubmissionCount): array
|
|
{
|
|
$values = $result['_values'] ?? [];
|
|
$values = \is_array($values) ? array_values(array_map('strval', $values)) : [];
|
|
$totalAnswers = count($values);
|
|
|
|
$result['totalAnswers'] = $totalAnswers;
|
|
$result['responseRate'] = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
|
|
|
|
switch ((string) ($result['type'] ?? 'text')) {
|
|
case 'yes_no_maybe':
|
|
$optionCounts = ['Ja' => 0, 'Nein' => 0];
|
|
|
|
// Wie beim Excel-Export: Bei erlaubtem "Vielleicht" die Zeile auch dann
|
|
// zeigen, wenn (noch) niemand "Vielleicht" gewählt hat.
|
|
if (!empty($result['_allowMaybe'])) {
|
|
$optionCounts['Vielleicht'] = 0;
|
|
}
|
|
|
|
foreach ($values as $value) {
|
|
$normalizedValue = mb_strtolower(trim($value));
|
|
|
|
if ('yes' === $normalizedValue) {
|
|
++$optionCounts['Ja'];
|
|
} elseif ('no' === $normalizedValue) {
|
|
++$optionCounts['Nein'];
|
|
} elseif ('maybe' === $normalizedValue) {
|
|
$optionCounts['Vielleicht'] = ($optionCounts['Vielleicht'] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
$result['options'] = $this->buildOptionStats($optionCounts);
|
|
$result['chart'] = $this->buildPieChartConfig($result['options']);
|
|
break;
|
|
|
|
case 'choice':
|
|
$optionCounts = [];
|
|
|
|
foreach (($result['_choiceOptions'] ?? []) as $choiceOption) {
|
|
$optionCounts[(string) $choiceOption] = 0;
|
|
}
|
|
|
|
foreach ($values as $value) {
|
|
foreach (array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry) as $selectedOption) {
|
|
$optionCounts[$selectedOption] = ($optionCounts[$selectedOption] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
$result['options'] = $this->buildOptionStats($optionCounts);
|
|
$result['chart'] = $this->buildPieChartConfig($result['options']);
|
|
break;
|
|
|
|
case 'range':
|
|
$numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values));
|
|
$distribution = [];
|
|
|
|
foreach ($numericValues as $numericValue) {
|
|
$label = (string) $numericValue;
|
|
$distribution[$label] = ($distribution[$label] ?? 0) + 1;
|
|
}
|
|
|
|
ksort($distribution, SORT_NATURAL);
|
|
|
|
$result['minimum'] = [] !== $numericValues ? min($numericValues) : null;
|
|
$result['maximum'] = [] !== $numericValues ? max($numericValues) : null;
|
|
$result['average'] = [] !== $numericValues ? round(array_sum($numericValues) / count($numericValues), 2) : null;
|
|
$result['distribution'] = $this->buildOptionStats($distribution);
|
|
$result['chart'] = $this->buildRangeChartConfig($result['distribution']);
|
|
break;
|
|
|
|
case 'text':
|
|
default:
|
|
$result['responses'] = array_values(array_filter($values, static fn (string $value): bool => '' !== trim($value)));
|
|
$result['responseSummary'] = $this->buildTextResponseSummary($result['responses']);
|
|
$result['chart'] = null;
|
|
break;
|
|
}
|
|
|
|
unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe']);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function getChoiceOptions(SurveyContentModel $question): array
|
|
{
|
|
$options = [];
|
|
|
|
for ($index = 1; $index <= 10; ++$index) {
|
|
$option = trim((string) $question->{'answerOption'.$index});
|
|
|
|
if ('' !== $option) {
|
|
$options[] = $option;
|
|
}
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $optionCounts
|
|
*
|
|
* @return list<array{label:string,count:int,percentage:int}>
|
|
*/
|
|
private function buildOptionStats(array $optionCounts): array
|
|
{
|
|
$total = array_sum($optionCounts);
|
|
$options = [];
|
|
|
|
foreach ($optionCounts as $label => $count) {
|
|
$options[] = [
|
|
'label' => $label,
|
|
'count' => $count,
|
|
'percentage' => $total > 0 ? (int) round(($count / $total) * 100) : 0,
|
|
];
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
/**
|
|
* @param list<array{label:string,count:int,percentage:int}> $options
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function buildPieChartConfig(array $options): ?array
|
|
{
|
|
if ([] === $options) {
|
|
return null;
|
|
}
|
|
|
|
$palette = $this->buildChartPalette(count($options));
|
|
|
|
return [
|
|
'type' => 'pie',
|
|
'data' => [
|
|
'labels' => array_column($options, 'label'),
|
|
'datasets' => [[
|
|
'data' => array_column($options, 'count'),
|
|
'backgroundColor' => array_column($palette, 'background'),
|
|
'borderColor' => array_column($palette, 'border'),
|
|
'borderWidth' => 2,
|
|
'hoverOffset' => 18,
|
|
]],
|
|
],
|
|
'options' => [
|
|
'responsive' => true,
|
|
'maintainAspectRatio' => false,
|
|
'plugins' => [
|
|
'legend' => [
|
|
'position' => 'bottom',
|
|
'labels' => [
|
|
'usePointStyle' => true,
|
|
'boxWidth' => 12,
|
|
'padding' => 18,
|
|
'color' => '#555E68',
|
|
'font' => [
|
|
'family' => 'PT Sans Narrow',
|
|
'size' => 12,
|
|
'weight' => '600',
|
|
],
|
|
],
|
|
],
|
|
'tooltip' => [
|
|
'backgroundColor' => 'rgba(34, 34, 34, 0.92)',
|
|
'titleFont' => [
|
|
'family' => 'Blogger Sans',
|
|
'size' => 14,
|
|
],
|
|
'bodyFont' => [
|
|
'family' => 'PT Sans Narrow',
|
|
'size' => 12,
|
|
],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<array{label:string,count:int,percentage:int}> $distribution
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function buildRangeChartConfig(array $distribution): ?array
|
|
{
|
|
if ([] === $distribution) {
|
|
return null;
|
|
}
|
|
|
|
$pointStyles = ['circle', 'rectRounded', 'triangle', 'star', 'rectRot', 'crossRot'];
|
|
$palette = $this->buildChartPalette(count($distribution));
|
|
|
|
return [
|
|
'type' => 'line',
|
|
'data' => [
|
|
'labels' => array_column($distribution, 'label'),
|
|
'datasets' => [[
|
|
'label' => 'Antworten',
|
|
'data' => array_column($distribution, 'count'),
|
|
'borderColor' => '#0064AD',
|
|
'backgroundColor' => 'rgba(236, 124, 50, 0.12)',
|
|
'pointBackgroundColor' => array_column($palette, 'border'),
|
|
'pointBorderColor' => '#ffffff',
|
|
'pointBorderWidth' => 3,
|
|
'pointHoverBorderWidth' => 3,
|
|
'pointRadius' => 7,
|
|
'pointHoverRadius' => 9,
|
|
'pointStyle' => array_map(
|
|
static fn (int $index): string => $pointStyles[$index % count($pointStyles)],
|
|
array_keys($distribution)
|
|
),
|
|
'fill' => true,
|
|
'tension' => 0.32,
|
|
]],
|
|
],
|
|
'options' => [
|
|
'responsive' => true,
|
|
'maintainAspectRatio' => false,
|
|
'plugins' => [
|
|
'legend' => [
|
|
'display' => false,
|
|
],
|
|
'tooltip' => [
|
|
'backgroundColor' => 'rgba(34, 34, 34, 0.92)',
|
|
'titleFont' => [
|
|
'family' => 'Blogger Sans',
|
|
'size' => 14,
|
|
],
|
|
'bodyFont' => [
|
|
'family' => 'PT Sans Narrow',
|
|
'size' => 12,
|
|
],
|
|
],
|
|
],
|
|
'scales' => [
|
|
'x' => [
|
|
'grid' => [
|
|
'display' => false,
|
|
],
|
|
'ticks' => [
|
|
'color' => '#555E68',
|
|
'font' => [
|
|
'family' => 'PT Sans Narrow',
|
|
'weight' => '600',
|
|
],
|
|
],
|
|
],
|
|
'y' => [
|
|
'beginAtZero' => true,
|
|
'ticks' => [
|
|
'precision' => 0,
|
|
'color' => '#555E68',
|
|
'font' => [
|
|
'family' => 'PT Sans Narrow',
|
|
'weight' => '600',
|
|
],
|
|
],
|
|
'grid' => [
|
|
'color' => 'rgba(162, 168, 180, 0.22)',
|
|
],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $responses
|
|
*
|
|
* @return list<array{label:string,count:int,percentage:int}>
|
|
*/
|
|
private function buildTextResponseSummary(array $responses): array
|
|
{
|
|
if ([] === $responses) {
|
|
return [];
|
|
}
|
|
|
|
$counts = [];
|
|
|
|
foreach ($responses as $response) {
|
|
$label = trim($response);
|
|
|
|
if ('' === $label) {
|
|
continue;
|
|
}
|
|
|
|
$counts[$label] = ($counts[$label] ?? 0) + 1;
|
|
}
|
|
|
|
arsort($counts);
|
|
$summary = [];
|
|
$remainingCount = 0;
|
|
$index = 0;
|
|
|
|
foreach ($counts as $label => $count) {
|
|
if ($index < 5) {
|
|
$summary[$label] = $count;
|
|
} else {
|
|
$remainingCount += $count;
|
|
}
|
|
|
|
++$index;
|
|
}
|
|
|
|
if ($remainingCount > 0) {
|
|
$summary['Weitere Antworten'] = $remainingCount;
|
|
}
|
|
|
|
return $this->buildOptionStats($summary);
|
|
}
|
|
|
|
/**
|
|
* @return list<array{background:string,border:string}>
|
|
*/
|
|
private function buildChartPalette(int $count): array
|
|
{
|
|
$palette = [
|
|
['background' => 'rgba(0, 100, 173, 0.16)', 'border' => '#0064AD'],
|
|
['background' => 'rgba(236, 124, 50, 0.24)', 'border' => '#EC7C32'],
|
|
['background' => 'rgba(157, 82, 118, 0.18)', 'border' => '#9D5276'],
|
|
['background' => 'rgba(0, 174, 151, 0.22)', 'border' => '#00AE97'],
|
|
['background' => 'rgba(252, 228, 214, 0.9)', 'border' => '#EC7C32'],
|
|
['background' => 'rgba(162, 168, 180, 0.28)', 'border' => '#555E68'],
|
|
['background' => 'rgba(233, 233, 235, 0.9)', 'border' => '#A2A8B4'],
|
|
['background' => 'rgba(34, 34, 34, 0.12)', 'border' => '#222222'],
|
|
];
|
|
|
|
$colors = [];
|
|
|
|
for ($index = 0; $index < max(1, $count); ++$index) {
|
|
$colors[] = $palette[$index % count($palette)];
|
|
}
|
|
|
|
return $colors;
|
|
}
|
|
} |