Initial survey bundle import
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
<?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,value:string}> $answers
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function buildQuestionResults(array $questions, array $answers, int $completedSubmissionCount): array
|
||||
{
|
||||
$answersByQuestion = [];
|
||||
|
||||
foreach ($answers as $answer) {
|
||||
$answersByQuestion[$answer['questionId']][] = $answer['value'];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($questions as $question) {
|
||||
$questionId = (int) $question->id;
|
||||
$values = $answersByQuestion[$questionId] ?? [];
|
||||
$totalAnswers = count($values);
|
||||
$responseRate = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
|
||||
|
||||
$result = [
|
||||
'id' => $questionId,
|
||||
'question' => (string) $question->question,
|
||||
'description' => (string) ($question->description ?? ''),
|
||||
'type' => (string) $question->type,
|
||||
'totalAnswers' => $totalAnswers,
|
||||
'responseRate' => $responseRate,
|
||||
'chart' => null,
|
||||
];
|
||||
|
||||
switch ((string) $question->type) {
|
||||
case 'yes_no_maybe':
|
||||
$optionCounts = [
|
||||
'Ja' => 0,
|
||||
'Nein' => 0,
|
||||
];
|
||||
|
||||
if ('1' === (string) $question->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 && isset($optionCounts['Vielleicht'])) {
|
||||
++$optionCounts['Vielleicht'];
|
||||
}
|
||||
}
|
||||
|
||||
$result['options'] = $this->buildOptionStats($optionCounts);
|
||||
$result['chart'] = $this->buildPieChartConfig($result['options']);
|
||||
break;
|
||||
|
||||
case 'choice':
|
||||
$optionCounts = [];
|
||||
|
||||
for ($index = 1; $index <= 10; ++$index) {
|
||||
$option = trim((string) $question->{'answerOption'.$index});
|
||||
|
||||
if ('' !== $option) {
|
||||
$optionCounts[$option] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($values as $value) {
|
||||
foreach (array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry) as $selectedOption) {
|
||||
if (!array_key_exists($selectedOption, $optionCounts)) {
|
||||
$optionCounts[$selectedOption] = 0;
|
||||
}
|
||||
|
||||
++$optionCounts[$selectedOption];
|
||||
}
|
||||
}
|
||||
|
||||
$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'] = $this->buildPieChartConfig($result['responseSummary']);
|
||||
break;
|
||||
}
|
||||
|
||||
$results[] = $result;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user