diff --git a/config/questionnaires/teamer_escort.yaml b/config/questionnaires/teamer_escort.yaml new file mode 100644 index 0000000..4a21264 --- /dev/null +++ b/config/questionnaires/teamer_escort.yaml @@ -0,0 +1,369 @@ +# Questionnaire a teamer fills in after an assignment with bus escort. +# +# Replaces the Google forms "RL-Feedback 25-26" and "Bus-Feedback 25-26"; the question-by-question +# spec, with every decision taken, is in .ddev/plans/questionnaires/. Questions whose answer the +# disposition already knows (name, job profile, house, dates, destination, escort pickup) are not +# asked: they are snapshotted into the response's context instead. +# +# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it. +# +# Labels, help texts, section titles and option labels may be edited in place. Renaming or +# removing a question or option key, or changing a type, needs a new version: copy this file to +# teamer_escort.v1.yaml first, then bump `version` here. Submitted answers are keyed by it. +# +# Question fields: key, label, type (text | textarea | choice | multi | date | rating), +# required (default false), help, widget (choice only: radio | dropdown, default radio), +# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only). + +key: teamer_escort +version: 1 +title: "Reiseleiter/-in Feedback" +description: |- + Nur durch euer Feedback können wir besser werden! 🙂 + + Bei Bewertunsskalen: 1= schlecht, 5 = sehr gut + +sections: + - key: organisation + title: "Organisation & Ablauf" + questions: + - key: office_briefing_rating + label: "Wie gut hast du dich vorab vom Büro gebrieft gefühlt?" + type: rating + required: true + + - key: office_briefing_low_reason + label: "Wenn 1 - 3 Sterne Bewertung, warum?" + type: textarea + required: true + show_if: { question: office_briefing_rating, lte: 3 } + + - key: team_week_seminar + label: "War das Wochenteam-Seminar hilfreich für dich? Warst du bzw. wurdest du gut vorbereitet?" + type: choice + required: true + options: + - { key: very_helpful, label: "Ja, die Schulung war sehr hilfreich und ich fühlte mich bestens vorbereitet." } + - { key: mostly_helpful, label: "Ja, größtenteils – einige Themen hätte man noch vertiefen können." } + - { key: partly_helpful, label: "Es ging – die Schulung war teilweise hilfreich, aber nicht ausreichend für alle Aufgaben." } + - { key: rather_not_helpful, label: "Eher nein – ich fühlte mich in vielen Bereichen unvorbereitet." } + - { key: not_helpful, label: "Nein, die Schulung war kaum hilfreich und hat mich nicht auf den Arbeitsalltag vorbereitet." } + - { key: not_attended, label: "Ich habe nicht teilgenommen." } + + - key: onsite_organisation_rating + label: "Wie gut lief die Reiseorganisation vor Ort?" + type: rating + required: true + + - key: had_organisational_problems + label: "Gab es organisatorische Probleme?" + type: choice + required: true + options: + - { key: "yes", label: "Ja" } + - { key: "no", label: "Nein" } + + - key: organisational_problems + label: "Wenn ja, welche?" + type: text + show_if: { question: had_organisational_problems, equals: "yes" } + + - key: communication + title: "Kommunikation & Zusammenarbeit" + questions: + - key: teamer_collaboration_rating + label: "Wie lief die Kommunikation & Zusammenarbeit mit den E&P Teamer:innen vor Ort während der Reise?" + type: rating + required: true + + - key: support_wished + label: "Welche Unterstützung hättest du dir zusätzlich gewünscht?" + type: textarea + + - key: house_manager_problem_handling + label: "Wie ist deine Hausleitung mit Problemen vor Ort umgegangen?" + type: choice + required: true + options: + - { key: very_good, label: "Sehr gut – Probleme wurden schnell, fair und lösungsorientiert behandelt." } + - { key: good, label: "Gut – die meisten Anliegen wurden ernst genommen und bearbeitet." } + - { key: mixed, label: "Teils gut/teils schlecht – manche Probleme wurden gelöst, andere nicht." } + - { key: rather_poor, label: "Eher schlecht – es wurde wenig unternommen oder nicht auf Augenhöhe kommuniziert." } + - { key: very_poor, label: "Sehr schlecht – Probleme wurden ignoriert oder verschlimmert." } + + - key: felt_overwhelmed + label: "Gab es Momente, in denen du dich überfordert gefühlt hast?" + type: choice + required: true + options: + - { key: "yes", label: "Ja" } + - { key: "no", label: "Nein" } + + - key: overwhelmed_reason + label: "Wenn ja, wodurch?" + type: textarea + show_if: { question: felt_overwhelmed, equals: "yes" } + + - key: improvement + title: "Verbesserung" + questions: + - key: wished_differently + label: "Was hättest du dir anders oder besser gewünscht?" + type: textarea + + - key: would_work_again + label: "Würdest du wieder als Reiseleitung für E&P arbeiten?" + type: choice + required: true + options: + - { key: "yes", label: "Ja" } + - { key: "no", label: "Nein" } + + - key: would_not_work_again_reason + label: "Wenn nein, warum nicht?" + type: textarea + show_if: { question: would_work_again, equals: "no" } + + - key: other + title: "Sonstiges" + questions: + - key: anything_else + label: |- + Du hast nun schon vieles beantwortet, + aber nirgendwo konntest du folgende Aspekte loswerden: + type: textarea + + - key: bus_trip + title: "Busfahrt" + questions: + - key: direction + label: "Hin- oder Rückfahrt" + type: choice + required: true + options: + - { key: outbound, label: "Hinfahrt" } + - { key: return, label: "Rückfahrt" } + + - key: bus_company + label: "Bus-Unternehmen:" + type: choice + required: true + options: + - { key: boonk, label: "Boonk Reisen" } + - { key: piccolonia, label: "Piccolonia" } + - { key: heess, label: "Heeß" } + - { key: finkbeiner, label: "Finkbeiner" } + - { key: other, label: "Anderes Unternehmen" } + + - key: bus_company_other + label: "Name des Busunternehmens:" + type: text + required: true + show_if: { question: bus_company, equals: other } + + - key: bus_team + title: "Busteam" + questions: + - key: driver_names + label: "Busfahrer-Namen:" + type: text + required: true + + - key: bus_first_pickup + title: "1. Abfahrtsort" + questions: + - key: on_time_at_first_pickup + label: "Bus pünktlich am 1. Abfahrtsort?" + type: choice + required: true + options: + - { key: "yes", label: "Ja" } + - { key: "no", label: "Nein" } + + - key: delay_at_first_pickup + label: "Wieviel Verspätung hatte der Bus am 1. Abfahrtsort?" + type: text + required: true + show_if: { question: on_time_at_first_pickup, equals: "no" } + + - key: bus_rating + title: "Bewertung Bus & Fahrer" + questions: + - key: overall_impression + label: "Gesamteindruck des Busses" + type: choice + required: true + options: + - { key: very_good, label: "Sehr gut" } + - { key: good, label: "Gut" } + - { key: ok, label: "OK" } + - { key: not_so_good, label: "nicht so gut" } + - { key: bad, label: "schlecht" } + + - key: vehicle_age + label: "Alter des Fahrzeugs" + type: choice + required: true + options: + - { key: very_new, label: "Sehr neu" } + - { key: new, label: "neu" } + - { key: ok, label: "OK" } + - { key: somewhat_old, label: "etwas älter" } + - { key: very_old, label: "sehr alt" } + + - key: seating_comfort + label: "Sitzkomfort im Bus" + type: choice + required: true + options: + - { key: very_good, label: "Sehr gut" } + - { key: good, label: "Gut" } + - { key: ok, label: "OK" } + - { key: not_so_good, label: "nicht so gut" } + - { key: bad, label: "schlecht" } + + - key: driver_friendliness + label: "Freundlichkeit der Fahrer" + type: choice + required: true + options: + - { key: very_friendly, label: "Sehr freundlich" } + - { key: friendly, label: "freundlich" } + - { key: ok, label: "OK" } + - { key: not_so_friendly, label: "nicht so freundlich" } + - { key: unfriendly, label: "unfreundlich" } + + - key: breaks + label: "Pausenzeiten" + type: choice + required: true + options: + - { key: very_good, label: "Sehr gut" } + - { key: good, label: "Gut" } + - { key: ok, label: "OK" } + - { key: not_so_good, label: "nicht so gut" } + - { key: unacceptable, label: "geht gar nicht" } + + - key: temperature + label: "Temperatur im Bus" + type: choice + required: true + options: + - { key: very_good, label: "Sehr gut" } + - { key: good, label: "Gut" } + - { key: ok, label: "OK" } + - { key: not_so_good, label: "nicht so gut" } + - { key: unacceptable, label: "geht gar nicht" } + + - key: guest_behaviour + label: "Verhalten der Gäste im Bus" + type: choice + required: true + options: + - { key: very_good, label: "Sehr gut" } + - { key: good, label: "Gut" } + - { key: ok, label: "OK" } + - { key: not_so_good, label: "nicht so gut" } + - { key: unacceptable, label: "geht gar nicht" } + + - key: bus_pickups + title: "Ladestellen" + questions: + - key: drivers_knew_pickups + label: "Waren den Fahrern alle Abfahrtsorte bekannt?" + type: choice + required: true + options: + - { key: "yes", label: "Ja" } + - { key: "no", label: "Nein" } + + - key: unknown_pickups + label: "Welche Abfahrtsorte waren nicht bekannt? Gab es dadurch Probleme?" + type: textarea + required: true + show_if: { question: drivers_knew_pickups, equals: "no" } + + - key: bus_destination + title: "Zielkenntnis" + questions: + - key: drivers_knew_destination + label: "War den Busfahrern der Zielort und die Ausladestelle bekannt?" + type: choice + required: true + options: + - { key: "yes", label: "Ja" } + - { key: "no", label: "Nein" } + + - key: bus_equipment + title: "Bus, Technik & Allgemeines" + questions: + - key: video + label: "Video/DVD-Gerät" + type: choice + required: true + options: + - { key: working, label: "funktionstüchtig" } + - { key: broken, label: "defekt" } + - { key: missing, label: "nicht vorhanden" } + + - key: wc + label: "WC" + type: choice + required: true + options: + - { key: working, label: "funktionstüchtig" } + - { key: broken, label: "defekt" } + - { key: closed, label: "nicht geöffnet" } + + - key: luggage + label: "Gepäck" + type: choice + required: true + options: + - { key: trailer, label: "Anhänger" } + - { key: ski_box, label: "Skikoffer" } + - { key: trailer_and_ski_box, label: "Anhänger und Skikoffer" } + - { key: none, label: "Kein Anhänger oder Skikoffer" } + + - key: smoking + label: "Rauchen im Bus" + type: choice + required: true + options: + - { key: driver, label: "Busfahrer hat im Bus geraucht" } + - { key: driver_and_guests, label: "Busfahrer und Gäste haben im Bus geraucht" } + - { key: nobody, label: "Es wurde nicht geraucht" } + + - key: snow_chains + label: "Schneeketten" + type: choice + required: true + options: + - { key: fitted, label: "Fahrer hat Ketten aufgezogen" } + - { key: not_needed, label: "Ketten wurden nicht gebraucht" } + - { key: not_fitted, label: "Ketten nicht aufgezogen (trotz starkem Schneefall)" } + + - key: bus_damage + title: "Schäden" + questions: + - key: bus_damage + label: "Entstanden während der Fahrt Schäden am Bus?" + type: choice + required: true + options: + - { key: none, label: "Keine Schäden" } + - { key: settled_with_driver, label: "Bereits mit Fahrer abgeklärt" } + - { key: claim_expected, label: "Da wird vom Busunternehmen noch was kommen" } + + - key: damage_description + label: "Was ist passiert?" + type: textarea + required: true + show_if: { question: bus_damage, equals: claim_expected } + + - key: bus_remarks + title: "Anmerkungen zur Busfahrt" + questions: + - key: bus_remarks + label: "Das ging gar nicht / ist besonders hervorzuheben:" + type: textarea diff --git a/config/services.yaml b/config/services.yaml index 5117d1f..4139e5b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -151,6 +151,8 @@ services: # Only the path enters the container - the file itself is parsed on demand by # App\Config\EmailTextCatalog, since its contents matter on few requests. $emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml' + # Likewise parsed on demand, by App\Config\QuestionnaireCatalog. + $questionnaireDir: '%kernel.project_dir%/config/questionnaires' $mailingBusTransport: '%mailing_bus_transport%' $seasonStart: '%season_start%' $seasonEnd: '%season_end%' diff --git a/src/Config/QuestionnaireCatalog.php b/src/Config/QuestionnaireCatalog.php new file mode 100644 index 0000000..4bf5c60 --- /dev/null +++ b/src/Config/QuestionnaireCatalog.php @@ -0,0 +1,344 @@ +.yaml` is the current version of a questionnaire; `.v.yaml` keeps an older one that + * responses were submitted under, so they can still be rendered with the labels they were given. + * + * Files are parsed lazily and memoised, like EmailTextCatalog. A definition is checked strictly + * while it is built - unknown fields, unknown types, duplicate keys and show_if conditions that + * point at a later question or a non-existent option all throw - because a typo in the YAML should + * fail in QuestionnaireCatalogTest, not in front of a teamer. + */ +class QuestionnaireCatalog +{ + private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections']; + private const array SECTION_FIELDS = ['key', 'title', 'questions']; + private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if']; + private const array OPTION_FIELDS = ['key', 'label']; + + // A question key becomes a form field name and a JSON key in stored answers. + private const string KEY_PATTERN = '/^[a-z][a-z0-9_]*$/'; + + private const int RATING_MIN = 1; + private const int RATING_MAX = 5; + + /** + * @var array keyed by file path + */ + private array $definitions = []; + + public function __construct( + private readonly string $questionnaireDir, + ) { + } + + /** + * @param int|null $version null for the current version + */ + public function get(string $key, ?int $version = null): Definition + { + $current = $this->load($this->questionnaireDir.'/'.$key.'.yaml'); + + if (null === $version || $version === $current->version) { + return $current; + } + + return $this->load(sprintf('%s/%s.v%d.yaml', $this->questionnaireDir, $key, $version)); + } + + /** + * Every definition in the directory, archived versions included. + * + * @return list + */ + public function all(): array + { + $files = glob($this->questionnaireDir.'/*.yaml') ?: []; + sort($files); + + return array_map($this->load(...), $files); + } + + private function load(string $file): Definition + { + if (!is_file($file)) { + throw new \InvalidArgumentException(sprintf('Questionnaire definition "%s" does not exist', $file)); + } + + return $this->definitions[$file] ??= $this->build(Yaml::parseFile($file), basename($file)); + } + + private function build(mixed $data, string $file): Definition + { + $this->assertFields($data, self::ROOT_FIELDS, ['key', 'version', 'title', 'sections'], $file); + + if (1 !== preg_match('/^(?[a-z0-9_]+)(\.v(?\d+))?\.yaml$/', $file, $name)) { + throw $this->error($file, 'file name must be .yaml or .v.yaml'); + } + + if ($data['key'] !== $name['key']) { + throw $this->error($file, sprintf('key "%s" does not match the file name', $data['key'])); + } + + if (!is_int($data['version']) || $data['version'] < 1) { + throw $this->error($file, 'version must be a positive integer'); + } + + if (isset($name['version']) && (int) $name['version'] !== $data['version']) { + throw $this->error($file, sprintf('version %d does not match the file name', $data['version'])); + } + + if (!is_array($data['sections']) || [] === $data['sections'] || !array_is_list($data['sections'])) { + throw $this->error($file, 'sections must be a non-empty list'); + } + + $sections = []; + $sectionKeys = []; + /** @var array $questions every question parsed so far, for show_if */ + $questions = []; + + foreach ($data['sections'] as $index => $sectionData) { + $context = sprintf('%s: section #%d', $file, $index + 1); + $this->assertFields($sectionData, self::SECTION_FIELDS, ['key', 'questions'], $context); + $key = $this->key($sectionData['key'], $context); + + if (in_array($key, $sectionKeys, true)) { + throw $this->error($context, sprintf('duplicate section key "%s"', $key)); + } + + $sectionKeys[] = $key; + $context = sprintf('%s: section "%s"', $file, $key); + + if (!is_array($sectionData['questions']) || [] === $sectionData['questions'] || !array_is_list($sectionData['questions'])) { + throw $this->error($context, 'questions must be a non-empty list'); + } + + $sectionQuestions = []; + + foreach ($sectionData['questions'] as $questionData) { + $question = $this->buildQuestion($questionData, $questions, $context); + $questions[$question->key] = $question; + $sectionQuestions[] = $question; + } + + $sections[] = new Section($key, $this->optionalString($sectionData['title'] ?? null, 'title', $context), $sectionQuestions); + } + + return new Definition( + $data['key'], + $data['version'], + $this->string($data['title'], 'title', $file), + $this->optionalString($data['description'] ?? null, 'description', $file), + $sections, + ); + } + + /** + * @param array $earlier + */ + private function buildQuestion(mixed $data, array $earlier, string $context): Question + { + $this->assertFields($data, self::QUESTION_FIELDS, ['key', 'label', 'type'], $context.': question'); + $key = $this->key($data['key'], $context.': question'); + $context = sprintf('%s: question "%s"', $context, $key); + + if (isset($earlier[$key])) { + throw $this->error($context, 'duplicate question key'); + } + + $type = QuestionType::tryFrom((string) $data['type']) + ?? throw $this->error($context, sprintf('unknown type "%s"', $data['type'])); + + $required = $data['required'] ?? false; + + if (!is_bool($required)) { + throw $this->error($context, 'required must be true or false'); + } + + $widget = null; + + if (array_key_exists('widget', $data)) { + if (QuestionType::CHOICE !== $type) { + throw $this->error($context, 'widget is only allowed on a choice question'); + } + + $widget = ChoiceWidget::tryFrom((string) $data['widget']) + ?? throw $this->error($context, sprintf('unknown widget "%s"', $data['widget'])); + } elseif (QuestionType::CHOICE === $type) { + $widget = ChoiceWidget::RADIO; + } + + $options = []; + + if ($type->hasOptions()) { + $options = $this->buildOptions($data['options'] ?? null, $context); + } elseif (array_key_exists('options', $data)) { + throw $this->error($context, sprintf('options are not allowed on a %s question', $type->value)); + } + + $showIf = array_key_exists('show_if', $data) + ? $this->buildCondition($data['show_if'], $earlier, $context) + : null; + + return new Question( + $key, + $this->string($data['label'], 'label', $context), + $type, + $required, + $this->optionalString($data['help'] ?? null, 'help', $context), + $widget, + $options, + $showIf, + ); + } + + /** + * @return list