From a9c81f4bfee5066d7f549ce4f02382e19efff6d8 Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Thu, 9 Jul 2026 16:32:47 +0200 Subject: [PATCH] feat(builder): score yes/no fields in the quiz scoring system Yes/No fields can now award points per answer, like single-choice options. The scores live on two synthetic options (values "yes"/"no") so they flow through the shared scoreSubmission, which maps the boolean answer to those values. The builder gains a points editor with a "Yes" and "No" row for yes_no fields; untouched fields carry no score and stay out of scoring. Localized in all 7 languages. --- .../src/components/builder/field-editor.tsx | 42 +++++++++++++++++++ apps/web/src/i18n/dictionaries.ts | 7 ++++ apps/web/src/lib/builder-fields.ts | 2 + packages/shared/src/scoring.test.ts | 9 ++++ packages/shared/src/scoring.ts | 20 ++++++--- 5 files changed, 75 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/builder/field-editor.tsx b/apps/web/src/components/builder/field-editor.tsx index c907be2..741038b 100644 --- a/apps/web/src/components/builder/field-editor.tsx +++ b/apps/web/src/components/builder/field-editor.tsx @@ -19,6 +19,7 @@ import { needsRows, needsSliderConfig, needsStarsConfig, + needsYesNoScore, } from "@/lib/builder-fields"; import type { Dictionary } from "@/i18n"; @@ -79,6 +80,24 @@ export function FieldEditor({ function removeOption(i: number) { patch({ options: (field.options ?? []).filter((_, j) => j !== i) }); } + /** + * Set the score for the "yes" or "no" answer of a yes_no field. Scores live on + * two synthetic options (values "yes"/"no") so they flow through the shared + * scoring, seeded on first edit. + */ + function setYesNoScore(which: "yes" | "no", raw: string) { + const base = + field.options?.length === 2 + ? [...field.options] + : [ + { value: "yes", label: "Yes" }, + { value: "no", label: "No" }, + ]; + const i = which === "yes" ? 0 : 1; + const n = raw === "" ? undefined : Number(raw); + base[i] = { ...base[i]!, score: n !== undefined && Number.isFinite(n) ? n : undefined }; + patch({ options: base }); + } function setValidation(partial: Partial) { patch({ validation: { ...field.validation, ...partial } }); } @@ -197,6 +216,29 @@ export function FieldEditor({ )} + {needsYesNoScore(field.type) && ( + +
+ {(["yes", "no"] as const).map((which) => ( +
+ + {which === "yes" ? t.yes : t.no} + +
+ o.value === which)?.score ?? ""} + onChange={(e) => setYesNoScore(which, e.target.value)} + placeholder={t.points} + title={t.points} + /> +
+
+ ))} +
+
+ )} + {needsRows(field.type) && (
diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index 8b39a34..87fecde 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -486,6 +486,7 @@ const en = { errSave: "Could not save the form.", fieldLabel: "Label", fieldText: "Text", helpText: "Help text", helpHint: "Optional, shown below the label.", options: "Options", optionPh: "Option", required: "Required", addOption: "Add option", points: "Points", + yes: "Yes", no: "No", yesNoScoreHint: "Optional points awarded for each answer (used for quiz scoring).", moveUp: "Move up", moveDown: "Move down", remove: "Remove", removeOption: "Remove option", labelPh: "Question label…", headingPh: "Section heading…", consentText: "Consent text", consentHint: "Shown next to the checkbox.", consentPh: "I agree to …", @@ -1035,6 +1036,7 @@ const de: Dictionary = { errSave: "Formular konnte nicht gespeichert werden.", fieldLabel: "Beschriftung", fieldText: "Text", helpText: "Hilfetext", helpHint: "Optional, unter der Beschriftung angezeigt.", options: "Optionen", optionPh: "Option", required: "Pflichtfeld", addOption: "Option hinzufügen", points: "Punkte", + yes: "Ja", no: "Nein", yesNoScoreHint: "Optionale Punkte pro Antwort (für die Quiz-Bewertung).", moveUp: "Nach oben", moveDown: "Nach unten", remove: "Entfernen", removeOption: "Option entfernen", labelPh: "Fragetext…", headingPh: "Abschnittsüberschrift…", consentText: "Consent-Text", consentHint: "Wird neben der Checkbox angezeigt.", consentPh: "Ich stimme … zu", @@ -1582,6 +1584,7 @@ const hu: Dictionary = { errSave: "Az űrlap mentése sikertelen.", fieldLabel: "Címke", fieldText: "Szöveg", helpText: "Súgószöveg", helpHint: "Opcionális, a címke alatt jelenik meg.", options: "Opciók", optionPh: "Opció", required: "Kötelező", addOption: "Opció hozzáadása", points: "Pont", + yes: "Igen", no: "Nem", yesNoScoreHint: "Opcionális pontszám válaszonként (kvíz-pontozáshoz).", moveUp: "Mozgatás felfelé", moveDown: "Mozgatás lefelé", remove: "Eltávolítás", removeOption: "Opció eltávolítása", labelPh: "Kérdés címkéje…", headingPh: "Szakasz címe…", consentText: "Hozzájárulás szövege", consentHint: "A jelölőnégyzet mellett jelenik meg.", consentPh: "Elfogadom a …", @@ -2129,6 +2132,7 @@ const fr: Dictionary = { errSave: "Impossible d'enregistrer le formulaire.", fieldLabel: "Libellé", fieldText: "Texte", helpText: "Texte d'aide", helpHint: "Optionnel, affiché sous le libellé.", options: "Options", optionPh: "Option", required: "Obligatoire", addOption: "Ajouter une option", points: "Points", + yes: "Oui", no: "Non", yesNoScoreHint: "Points facultatifs pour chaque réponse (pour le score du quiz).", moveUp: "Monter", moveDown: "Descendre", remove: "Supprimer", removeOption: "Supprimer l'option", labelPh: "Libellé de la question…", headingPh: "Titre de section…", consentText: "Texte de consentement", consentHint: "Affiché à côté de la case.", consentPh: "J'accepte …", @@ -2677,6 +2681,7 @@ const es: Dictionary = { errSave: "No se pudo guardar el formulario.", fieldLabel: "Etiqueta", fieldText: "Texto", helpText: "Texto de ayuda", helpHint: "Opcional, se muestra debajo de la etiqueta.", options: "Opciones", optionPh: "Opción", required: "Obligatorio", addOption: "Agregar opción", points: "Puntos", + yes: "Sí", no: "No", yesNoScoreHint: "Puntos opcionales por respuesta (para la puntuación del cuestionario).", moveUp: "Mover arriba", moveDown: "Mover abajo", remove: "Eliminar", removeOption: "Eliminar opción", labelPh: "Etiqueta de la pregunta…", headingPh: "Encabezado de sección…", consentText: "Texto de consentimiento", consentHint: "Se muestra junto a la casilla.", consentPh: "Acepto …", @@ -3225,6 +3230,7 @@ const pt: Dictionary = { errSave: "Não foi possível salvar o formulário.", fieldLabel: "Rótulo", fieldText: "Texto", helpText: "Texto de ajuda", helpHint: "Opcional, exibido abaixo do rótulo.", options: "Opções", optionPh: "Opção", required: "Obrigatório", addOption: "Adicionar opção", points: "Pontos", + yes: "Sim", no: "Não", yesNoScoreHint: "Pontos opcionais por resposta (para a pontuação do quiz).", moveUp: "Mover para cima", moveDown: "Mover para baixo", remove: "Remover", removeOption: "Remover opção", labelPh: "Rótulo da pergunta…", headingPh: "Título da seção…", consentText: "Texto de consentimento", consentHint: "Exibido ao lado da caixa.", consentPh: "Concordo com …", @@ -3773,6 +3779,7 @@ const pl: Dictionary = { errSave: "Nie udało się zapisać formularza.", fieldLabel: "Etykieta", fieldText: "Tekst", helpText: "Tekst pomocy", helpHint: "Opcjonalnie, wyświetlany pod etykietą.", options: "Opcje", optionPh: "Opcja", required: "Wymagane", addOption: "Dodaj opcję", points: "Punkty", + yes: "Tak", no: "Nie", yesNoScoreHint: "Opcjonalne punkty za każdą odpowiedź (do punktacji quizu).", moveUp: "Przesuń w górę", moveDown: "Przesuń w dół", remove: "Usuń", removeOption: "Usuń opcję", labelPh: "Etykieta pytania…", headingPh: "Nagłówek sekcji…", consentText: "Tekst zgody", consentHint: "Wyświetlany obok pola wyboru.", consentPh: "Zgadzam się na …", diff --git a/apps/web/src/lib/builder-fields.ts b/apps/web/src/lib/builder-fields.ts index 6e10a2b..95e1063 100644 --- a/apps/web/src/lib/builder-fields.ts +++ b/apps/web/src/lib/builder-fields.ts @@ -44,6 +44,8 @@ const STARS_CONFIG_TYPES: FieldType[] = ["rating_stars"]; const SLIDER_CONFIG_TYPES: FieldType[] = ["slider"]; export const needsOptions = (type: FieldType): boolean => CHOICE_TYPES.includes(type); +/** Yes/No fields expose a points editor for the "yes" and "no" answers. */ +export const needsYesNoScore = (type: FieldType): boolean => type === "yes_no"; export const isLayoutType = (type: FieldType): boolean => BUILDER_LAYOUT_TYPES.includes(type); /** Calculated fields configure an arithmetic formula instead of an input. */ export const needsFormula = (type: FieldType): boolean => type === "calculated"; diff --git a/packages/shared/src/scoring.test.ts b/packages/shared/src/scoring.test.ts index eb07b25..fdec8e5 100644 --- a/packages/shared/src/scoring.test.ts +++ b/packages/shared/src/scoring.test.ts @@ -25,6 +25,15 @@ describe("scoreSubmission", () => { expect(scoreSubmission(s, {})).toBe(0); }); + it("scores a yes_no field from its synthetic yes/no options", () => { + const s = spec([ + { id: "q", type: "yes_no", width: "full", validation: { required: false }, conditional: [], options: [{ value: "yes", label: "Yes", score: 10 }, { value: "no", label: "No", score: 0 }] }, + ]); + expect(scoreSubmission(s, { q: true })).toBe(10); + expect(scoreSubmission(s, { q: false })).toBe(0); + expect(scoreSubmission(s, {})).toBe(0); // unanswered + }); + it("sums every selected option for multi-choice and across fields", () => { const s = spec([ { id: "q1", type: "multi_choice", width: "full", validation: { required: false }, conditional: [], options: [{ value: "x", label: "X", score: 3 }, { value: "y", label: "Y", score: 5 }, { value: "z", label: "Z", score: 0 }] }, diff --git a/packages/shared/src/scoring.ts b/packages/shared/src/scoring.ts index 74d6ad6..1cb94a8 100644 --- a/packages/shared/src/scoring.ts +++ b/packages/shared/src/scoring.ts @@ -8,6 +8,9 @@ type Answers = Record; * `score`; a submission's score is the sum of the scores of the options the * applicant selected, across every field. Returns null when no option in the * form has a score (scoring isn't used) so non-quiz forms stay scoreless. + * + * `yes_no` fields join the same system: their score is stored on two synthetic + * options with the values `"yes"`/`"no"`, matched against the boolean answer. */ export function scoreSubmission(spec: FormSpec, answers: Answers): number | null { let scored = false; @@ -20,11 +23,18 @@ export function scoreSubmission(spec: FormSpec, answers: Answers): number | null scored = true; const answer = answers[field.id]; - const selected = Array.isArray(answer) - ? answer.map(String) - : answer === undefined || answer === null - ? [] - : [String(answer)]; + const selected = + field.type === "yes_no" + ? answer === true + ? ["yes"] + : answer === false + ? ["no"] + : [] + : Array.isArray(answer) + ? answer.map(String) + : answer === undefined || answer === null + ? [] + : [String(answer)]; for (const option of options) { if (typeof option.score === "number" && selected.includes(option.value)) {