Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions data/spec-driven-development-assessment-A.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions data/spec-driven-development-assessment-B.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/data/loadAssessment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.<n>" 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 = [];
Expand Down
106 changes: 103 additions & 3 deletions src/screens/results.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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));

Expand All @@ -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) {
Expand Down
83 changes: 83 additions & 0 deletions src/survey.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,string>} prefill - field -> entry id (hidden values)
* @param {Record<string,any>} values - field -> value
* @param {Record<string,string>} 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;
}
}
50 changes: 50 additions & 0 deletions styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
56 changes: 55 additions & 1 deletion test/telemetry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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);
});
Loading