From 470653369347003d12a810ca84fc9c2f5a37be93 Mon Sep 17 00:00:00 2001 From: Jerry Date: Thu, 12 Nov 2026 08:40:56 +0800 Subject: [PATCH] Render the survey in-page so uh/variant are hidden Google Forms has no hidden fields, so the prefilled uh/variant questions were visible and editable in the embedded form. The results screen now renders the survey questions itself (survey_questions in the assessment JSON) and posts them, plus uh/variant, to the form's formResponse endpoint. Responses still land in the same Form Responses tabs. One response per attempt; falls back to the iframe when no questions are set. Co-Authored-By: Claude Opus 5.5 --- .../spec-driven-development-assessment-A.json | 7 ++ .../spec-driven-development-assessment-B.json | 7 ++ src/data/loadAssessment.js | 5 +- src/screens/results.js | 106 +++++++++++++++++- src/survey.js | 83 ++++++++++++++ styles/app.css | 50 +++++++++ test/telemetry.test.js | 56 ++++++++- 7 files changed, 309 insertions(+), 5 deletions(-) diff --git a/data/spec-driven-development-assessment-A.json b/data/spec-driven-development-assessment-A.json index 81fb285..3953d6b 100644 --- a/data/spec-driven-development-assessment-A.json +++ b/data/spec-driven-development-assessment-A.json @@ -2,6 +2,13 @@ "capability_name": "Spec-Driven Development with Agentic Coding", "survey_url": "https://docs.google.com/forms/d/e/1FAIpQLSdFUrb9EdU3CVbv5DtY8zpEl7u0ftDMj_uyNXS0PIWI2uhMyA/viewform", "survey_prefill": { "uh": "entry.629826937", "variant": "entry.1261750357" }, + "survey_questions": [ + {"entry": "entry.1533954358", "type": "scale", "title": "How would you rate the difficulty of the assessment?", "min": 1, "max": 5, "min_label": "Too easy", "max_label": "Too hard"}, + {"entry": "entry.1116215102", "type": "scale", "title": "How would you rate the length of the assessment?", "min": 1, "max": 5, "min_label": "Too short", "max_label": "Too long"}, + {"entry": "entry.1166678138", "type": "scale", "title": "How confident are you that passing this assessment shows you’ve mastered the material?", "min": 1, "max": 5, "min_label": "Not at all", "max_label": "Very confident"}, + {"entry": "entry.34512584", "type": "scale", "title": "How likely would you be to subscribe to a PRO membership to earn a Certificate of Completion if this assessment was offered?", "min": 1, "max": 5, "min_label": "Very unlikely", "max_label": "Very likely"}, + {"entry": "entry.508072925", "type": "paragraph", "title": "What would make this assessment more valuable to you?"} + ], "status": "draft", "audience": "Beginners to spec-driven and agentic coding workflows", "estimated_duration": "25-30 minutes", diff --git a/data/spec-driven-development-assessment-B.json b/data/spec-driven-development-assessment-B.json index fd12ee8..b6d8e75 100644 --- a/data/spec-driven-development-assessment-B.json +++ b/data/spec-driven-development-assessment-B.json @@ -2,6 +2,13 @@ "capability_name": "Spec-Driven Development with Agentic Coding", "survey_url": "https://docs.google.com/forms/d/e/1FAIpQLSfNBIxwA3Uh3icZ8tnA_cGDqCUZdqzwmxT8Y3OWGXkMhhgPow/viewform", "survey_prefill": { "uh": "entry.414250295", "variant": "entry.404243219" }, + "survey_questions": [ + {"entry": "entry.1469478207", "type": "scale", "title": "How would you rate the difficulty of the assessment?", "min": 1, "max": 5, "min_label": "Too easy", "max_label": "Too hard"}, + {"entry": "entry.872097717", "type": "scale", "title": "How would you rate the length of the assessment?", "min": 1, "max": 5, "min_label": "Too short", "max_label": "Too long"}, + {"entry": "entry.1299108271", "type": "scale", "title": "How confident are you that passing this assessment shows you’ve mastered the material?", "min": 1, "max": 5, "min_label": "Not at all", "max_label": "Very confident"}, + {"entry": "entry.1238470649", "type": "scale", "title": "How likely would you be to subscribe to a PRO membership to earn a Certificate of Completion if this assessment was offered?", "min": 1, "max": 5, "min_label": "Very unlikely", "max_label": "Very likely"}, + {"entry": "entry.971168748", "type": "paragraph", "title": "What would make this assessment more valuable to you?"} + ], "status": "draft", "audience": "Beginners to spec-driven and agentic coding workflows", "estimated_duration": "25-30 minutes", diff --git a/src/data/loadAssessment.js b/src/data/loadAssessment.js index 8d437cc..f1be742 100644 --- a/src/data/loadAssessment.js +++ b/src/data/loadAssessment.js @@ -12,7 +12,7 @@ // // This module does NO shuffling and NO grading — those live in later milestones. -import { normalizePrefill } from "../survey.js"; +import { normalizePrefill, normalizeQuestions } from "../survey.js"; const POINTS_PER_QUESTION = 1; // SPEC §5 @@ -115,6 +115,9 @@ export function normalizeAssessment(raw) { survey_url: httpUrl(raw.survey_url), // Optional Google Form prefill: our field name -> the form's "entry." id. survey_prefill: normalizePrefill(raw.survey_prefill), + // Optional in-page survey questions (posted to the form's formResponse); + // when empty the results screen falls back to embedding the form. + survey_questions: normalizeQuestions(raw.survey_questions), }; const challenges = []; diff --git a/src/screens/results.js b/src/screens/results.js index 6a8f44a..0e53c80 100644 --- a/src/screens/results.js +++ b/src/screens/results.js @@ -8,7 +8,14 @@ import { focusHeading, announce } from "../util/a11y.js"; import { widgetFor } from "../questions/registry.js"; import { grade } from "../scoring.js"; import { startFresh } from "../session.js"; -import { buildSurveyUrl } from "../survey.js"; +import { + buildSurveyUrl, + buildSurveyBody, + formResponseUrl, + submitSurvey, +} from "../survey.js"; +import { getItem, setItem } from "../util/storage.js"; +import { currentAttempt } from "../telemetry.js"; const OUTCOME = { correct: { shape: "✓", label: "Correct" }, @@ -29,12 +36,15 @@ export function renderResults(root, app) { const challenges = model.challenges.filter((ch) => ch.questions.length); section.append(reviewNav(challenges)); // jump to any challenge's review (SPEC §3.4) - const survey = surveyBlock(model.meta, { + const surveyValues = { uh: app.learnerId, variant: app.assessment && app.assessment.variant, score: fmt(r.totalEarned), percentage: r.percentage, - }); // null when no survey configured + }; + const survey = model.meta.survey_questions.length + ? inPageSurvey(model.meta, surveyValues, surveyDoneKey(app)) + : surveyBlock(model.meta, surveyValues); // null when no survey configured if (survey) section.append(survey); for (const ch of challenges) section.append(challengeBlock(ch, r, app)); @@ -54,6 +64,96 @@ export function renderResults(root, app) { announce(`Results: ${fmt(r.totalEarned)} of ${r.totalPossible} points, ${r.percentage} percent.`); } +// Post-assessment survey rendered in the page and posted to the Google Form, so +// uh / variant travel as hidden values the learner never sees. One response per +// attempt: once sent, a re-render shows the thank-you note instead. +function surveyDoneKey(app) { + const attempt = currentAttempt(app.sessionKey); + return `assessment:survey:${app.sessionKey}:${attempt ? attempt.session_id : ""}`; +} + +function inPageSurvey(meta, values, doneKey) { + const action = formResponseUrl(meta.survey_url); + if (!action) return null; + + const wrap = el("aside", "results-survey"); + wrap.setAttribute("aria-label", "Survey"); + const thanks = () => { + wrap.replaceChildren(el("p", "results-survey__text", "Thanks for your feedback!")); + }; + if (getItem(doneKey)) { + thanks(); + return wrap; + } + + const form = el("form", "survey"); + form.noValidate = true; + form.append(el("p", "results-survey__text", "One last step — please answer this 2-minute survey.")); + + meta.survey_questions.forEach((q, i) => { + const id = `survey-q${i}`; + if (q.type === "scale") { + const fs = el("fieldset", "survey__q"); + fs.append(el("legend", "survey__title", q.title)); + const row = el("div", "survey__scale"); + if (q.min_label) row.append(el("span", "survey__end", q.min_label)); + for (let n = q.min; n <= q.max; n++) { + const label = el("label", "survey__opt"); + const input = el("input"); + input.type = "radio"; + input.name = q.entry; + input.value = String(n); + label.append(input, el("span", null, String(n))); + row.append(label); + } + if (q.max_label) row.append(el("span", "survey__end", q.max_label)); + fs.append(row); + form.append(fs); + } else { + const box = el("div", "survey__q"); + const label = el("label", "survey__title", q.title); + label.htmlFor = id; + const ta = el("textarea", "survey__text"); + ta.id = id; + ta.name = q.entry; + ta.rows = 3; + ta.maxLength = 2000; + box.append(label, ta); + form.append(box); + } + }); + + const status = el("p", "survey__status"); + status.setAttribute("role", "status"); + const submit = el("button", "dl-btn dl-btn--primary", "Submit survey"); + submit.type = "submit"; + form.append(submit, status); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const answers = {}; + for (const [k, v] of new FormData(form).entries()) answers[k] = v; + if (!Object.values(answers).some((v) => String(v).trim())) { + status.textContent = "Please answer at least one question."; + return; + } + submit.disabled = true; + status.textContent = "Sending…"; + const ok = await submitSurvey(action, buildSurveyBody(meta.survey_prefill, values, answers)); + if (ok) { + setItem(doneKey, "1"); + thanks(); + announce("Thanks for your feedback!"); + } else { + submit.disabled = false; + status.textContent = "Couldn't send. Please check your connection and try again."; + } + }); + + wrap.append(form); + return wrap; +} + // Post-assessment survey, embedded (Google Form, prefilled with the learner id + // variant + score so responses join the results sheet), plus a new-tab fallback. function surveyBlock(meta, values) { diff --git a/src/survey.js b/src/survey.js index 9a1c35c..e095b64 100644 --- a/src/survey.js +++ b/src/survey.js @@ -42,3 +42,86 @@ export function buildSurveyUrl(baseUrl, prefill, values, { embedded = false } = if (embedded) u.searchParams.set("embedded", "true"); return u.href; } + +// --------------------------------------------------------------------------- +// In-page survey: the questions are rendered by the results screen and posted +// straight to the Google Form's formResponse endpoint, so `uh` / `variant` are +// sent without being shown to (or editable by) the learner. + +const QUESTION_TYPES = new Set(["scale", "paragraph"]); + +/** + * survey_questions from the assessment JSON, e.g. + * { entry: "entry.1", type: "scale", title, min: 1, max: 5, min_label, max_label } + * { entry: "entry.2", type: "paragraph", title } + * Invalid items are dropped. + */ +export function normalizeQuestions(raw) { + if (!Array.isArray(raw)) return []; + const out = []; + for (const q of raw) { + if (!q || typeof q !== "object") continue; + if (!ENTRY_RE.test(q.entry) || !QUESTION_TYPES.has(q.type)) continue; + if (typeof q.title !== "string" || !q.title.trim()) continue; + const item = { entry: q.entry, type: q.type, title: q.title }; + if (q.type === "scale") { + const min = Number.isInteger(q.min) ? q.min : 1; + const max = Number.isInteger(q.max) ? q.max : 5; + if (max <= min || max - min > 10) continue; + Object.assign(item, { + min, + max, + min_label: typeof q.min_label === "string" ? q.min_label : "", + max_label: typeof q.max_label === "string" ? q.max_label : "", + }); + } + out.push(item); + } + return out; +} + +/** ".../viewform?x" -> ".../formResponse"; "" if it isn't a Google Form URL. */ +export function formResponseUrl(viewformUrl) { + let u; + try { + u = new URL(viewformUrl); + } catch { + return ""; + } + if (u.hostname !== "docs.google.com" || !/\/viewform$/.test(u.pathname)) return ""; + return `${u.origin}${u.pathname.replace(/\/viewform$/, "/formResponse")}`; +} + +/** + * @param {Record} prefill - field -> entry id (hidden values) + * @param {Record} values - field -> value + * @param {Record} answers - entry id -> learner's answer + * @returns {URLSearchParams} + */ +export function buildSurveyBody(prefill, values, answers) { + const body = new URLSearchParams(); + for (const [entry, v] of Object.entries(answers || {})) { + if (ENTRY_RE.test(entry) && v !== undefined && v !== null && String(v).trim() !== "") { + body.set(entry, String(v)); + } + } + for (const [field, entry] of Object.entries(prefill || {})) { + const v = values ? values[field] : undefined; + if (v !== undefined && v !== null && v !== "") body.set(entry, String(v)); + } + return body; +} + +/** + * POST the answers to Google Forms. `no-cors` means we can't read the reply, so + * this resolves true once the request went out and false on a network error. + */ +export async function submitSurvey(action, body, fetchImpl = globalThis.fetch) { + if (!action || typeof fetchImpl !== "function") return false; + try { + await fetchImpl(action, { method: "POST", mode: "no-cors", body }); + return true; + } catch { + return false; + } +} diff --git a/styles/app.css b/styles/app.css index 01fa72b..659b7dd 100644 --- a/styles/app.css +++ b/styles/app.css @@ -663,6 +663,56 @@ body { .results-survey__link { font-size: 0.9rem; } +.survey { + display: flex; + flex-direction: column; + gap: 1.1rem; +} +.survey__q { + border: 0; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.survey__title { + font-weight: 500; + padding: 0; +} +.survey__scale { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.9rem; +} +.survey__end { + font-size: 0.85rem; + color: var(--app-text-muted); +} +.survey__opt { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + min-width: 44px; /* touch target */ + cursor: pointer; +} +.survey__text { + width: 100%; + box-sizing: border-box; + font: inherit; + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 6px; +} +.survey__status:empty { + display: none; +} +.survey__status { + margin: 0; + font-size: 0.9rem; +} .results-challenge { display: flex; flex-direction: column; diff --git a/test/telemetry.test.js b/test/telemetry.test.js index 9cb8b88..3e255e2 100644 --- a/test/telemetry.test.js +++ b/test/telemetry.test.js @@ -14,7 +14,14 @@ import { buildSubmittedEvent, sendEvent, } from "../src/telemetry.js"; -import { normalizePrefill, buildSurveyUrl } from "../src/survey.js"; +import { + normalizePrefill, + buildSurveyUrl, + normalizeQuestions, + formResponseUrl, + buildSurveyBody, + submitSurvey, +} from "../src/survey.js"; import { normalizeManifest } from "../src/data/loadAssessment.js"; beforeEach(() => _resetForTests()); @@ -137,3 +144,50 @@ test("normalizeManifest passes through a safe variant", () => { assert.equal(a.variant, "A"); assert.equal(b.variant, ""); }); + +test("normalizeQuestions keeps valid scale/paragraph items only", () => { + const qs = normalizeQuestions([ + { entry: "entry.1", type: "scale", title: "Hard?", min_label: "Easy", max_label: "Hard" }, + { entry: "entry.2", type: "paragraph", title: "Why?" }, + { entry: "bad", type: "scale", title: "x" }, + { entry: "entry.3", type: "checkbox", title: "x" }, + { entry: "entry.4", type: "paragraph", title: " " }, + { entry: "entry.5", type: "scale", title: "x", min: 5, max: 1 }, + ]); + assert.deepEqual(qs, [ + { entry: "entry.1", type: "scale", title: "Hard?", min: 1, max: 5, min_label: "Easy", max_label: "Hard" }, + { entry: "entry.2", type: "paragraph", title: "Why?" }, + ]); + assert.deepEqual(normalizeQuestions(undefined), []); +}); + +test("formResponseUrl only maps Google Form viewform URLs", () => { + assert.equal( + formResponseUrl("https://docs.google.com/forms/d/e/ID/viewform?usp=header"), + "https://docs.google.com/forms/d/e/ID/formResponse" + ); + assert.equal(formResponseUrl("https://evil.example/forms/d/e/ID/viewform"), ""); + assert.equal(formResponseUrl("nope"), ""); +}); + +test("buildSurveyBody sends answers plus hidden uh/variant, skipping blanks", () => { + const body = buildSurveyBody( + { uh: "entry.9", variant: "entry.8" }, + { uh: "u1", variant: "B" }, + { "entry.1": "4", "entry.2": " ", "not-an-entry": "x" } + ); + assert.deepEqual(Object.fromEntries(body), { "entry.1": "4", "entry.9": "u1", "entry.8": "B" }); +}); + +test("submitSurvey posts no-cors and reports network failures", async () => { + const calls = []; + const ok = await submitSurvey("https://docs.google.com/forms/d/e/ID/formResponse", new URLSearchParams("a=1"), (u, o) => { + calls.push([u, o]); + return Promise.resolve({}); + }); + assert.equal(ok, true); + assert.equal(calls[0][1].mode, "no-cors"); + assert.equal(calls[0][1].method, "POST"); + assert.equal(await submitSurvey("https://x/formResponse", new URLSearchParams(), () => Promise.reject(new Error("offline"))), false); + assert.equal(await submitSurvey("", new URLSearchParams(), () => Promise.resolve()), false); +});