Skip to main content

Questionnaire service

Questionnaires are no longer part of the tracking app. They are served by a questionnaire service that the app loads in a webview, so a questionnaire can change without shipping an app build.

Two are served today: the wellness daily check-in and session RPE, the post-workout effort rating. A third is an entry in registry.ts plus its own folder — not a new route, a new URL, or a new host contract.

The service currently lives in the portal, under apps/portal/src/questionnaire-service/ — a deliberate first step rather than its final home: the intent is to move it into its own deployment. Everything below is shaped by that — the service is one folder, one URL, and one message protocol, and it reaches into no portal-specific code.

Surfaces​

SurfaceRouteWho opens it
The service/questionnaire/The tracking app's check-in and session-RPE sheets, in an iframe
The console/dashboard/questionnaire-serviceCoaches/developers, to preview it and read what it emits

The service route sits outside /dashboard, so it inherits the root layout's providers but none of the dashboard chrome or its token gate — the same arrangement as /migrate and /export. It is never linked from portal navigation.

URL parameters​

/questionnaire/?id=wellness&host=<origin>&audience=athlete&theme=dark&lang=de

ParamValuesDefaultPurpose
idwellness | session-rpewellness only when absentWhich questionnaire (see registry.ts)
hostan origin—The host's own origin, so the service posts to an explicit target
audienceathlete | coachathleteWhich summary to show after the last question
themelight | darkOS preferencePins the token set to the host's theme
langBCP-47navigator.languageLocale hint

An id this deployment doesn't serve​

resolveQuestionnaireId treats absent and unknown differently, and the distinction matters more than it looks:

  • Absent → wellness. Opening the service URL directly is legitimate and has no questionnaire in mind, so a default is right.
  • Present but unknown → null, rendered as an explicit "this questionnaire isn't available" panel. A host that named a questionnaire must never be served a different one.

This is the failure mode to recognise when a host looks broken: the host is newer than the deployed service. A tracking build asking for ?id=session-rpe against a deployment whose registry.ts only lists wellness used to render the wellness questionnaire under the host's own "Session RPE" chrome — and any answers submitted were written as wellness measurements. The service and every host that addresses it have to be deployed together; this only makes the mismatch visible instead of silently wrong.

The lookup uses Object.hasOwn, not in: in walks the prototype chain, so ?id=toString would otherwise resolve to a "known" questionnaire that renders nothing.

The bearer token and user id are never URL parameters. They cross the message channel instead (below), because a token in a URL lands in history, the Referer header, server access logs, and crash reports.

The trailing slash matters in production: the portal is a static export with trailingSlash: true. In dev the bare path 308-redirects and keeps the query string, so both forms work there.

Which host the tracking app loads​

Resolved by service-url.ts, highest precedence first:

SourceValue
window.__ENODE_QUESTIONNAIRE_URL__native injection at document start
NEXT_PUBLIC_QUESTIONNAIRE_URLweb build time — the tunnel escape hatch
next devhttp://localhost:3001
a dev buildhttps://portaldev.enode.ai
a production buildhttps://portal.enode.ai

Dev-vs-production is keyed on isDebugViewEnabled() — the same flag that decides whether the /debug area exists — because that is the distinction being drawn, and CI already gets it right by passing no flag. NODE_ENV cannot stand in: next build sets it to production for a debug APK and a release APK alike, so it only separates next dev from everything else.

Getting past portaldev's deployment protection​

portaldev.enode.ai sits behind Vercel Deployment Protection. Measured 2026-08-21: every path answers 302 → Vercel SSO with x-frame-options: DENY — /questionnaire/, /_next/static/** and /favicon.ico alike. So the frame cannot load there, and authenticating only the frame's own URL would not help: the document's script and style requests carry no query string and would be redirected away, leaving a blank frame.

A dev build therefore appends two parameters, and only when the resolved origin is portaldev:

?x-vercel-protection-bypass=<secret>&x-vercel-set-bypass-cookie=samesitenone

samesitenone, not true. The frame is a third-party context — the app is served from https://localhost, the frame from portaldev — and SameSite is evaluated against the top-level site, so a Lax cookie is not sent even for the frame's own same-origin assets. Verified: =true yields SameSite=Lax, =samesitenone yields SameSite=None; with that cookie the page and its assets return 200, and without it the same asset returns 302.

Android WebView also rejects third-party cookies by default, so MainActivity.allowThirdPartyCookiesForDebug() opts in — debug builds only.

The secret is a Vercel Protection Bypass for Automation value, supplied per build via apps/tracking/.env.local (gitignored) as NEXT_PUBLIC_QUESTIONNAIRE_BYPASS, with no committed fallback — CI sets nothing, so a released bundle contains no secret. The guard in applyProtectionBypass is on the resolved origin, so neither a tunnel override nor a release build can carry it; service-url.test.ts pins that.

Two limits worth knowing:

  • iOS is not covered. WKWebView's tracking prevention blocks third-party cookies with no equivalent opt-out. This is an Android dev-testing workaround.
  • Production needs none of it. portal.enode.ai has no protection layer and sends no framing header.

The host bridge​

One contract, both directions, in host-bridge.ts.

Service → host

EventMeaning
readyThe service loaded. Hosts time out on this to detect an unreachable service.
authenticatedA token arrived and was accepted; carries userId.
submittedThe athlete finished. Discriminated on id: wellness carries a CheckInPayload, session-rpe a SessionRpePayload, so a host switching on the id gets the exact shape.
cancelledThe athlete backed out from inside the service.

Host → service

CommandMeaning
authThe user's bearer token and id.

Three transports, tried together — only one exists at a time:

  1. iframe — postMessage to the origin declared in ?host=.
  2. iOS WKWebView — window.webkit.messageHandlers.enodeQuestionnaire.
  3. Android — window.AndroidQuestionnaire (addJavascriptInterface).

For a native webview the host may instead inject window.__ENODE_QUESTIONNAIRE_TOKEN__ and window.__ENODE_QUESTIONNAIRE_USER_ID__ with a WKUserScript at .atDocumentStart, exactly as the migration route does.

Handshake​

  1. The host builds the URL with its own origin in ?host= and mounts the frame.
  2. The service posts ready.
  3. The host verifies event.origin, then posts auth at the service's origin.
  4. The service verifies event.origin, holds the token in memory, and posts authenticated. It deliberately does not call preloadAfterAuth() or write the token to this origin's storage — see the header comment in host-auth.ts.

Origin rules​

These protect a credential, so they are not optional:

  • No "*" target origin, in either direction.
  • ?host= is checked against an allowlist (ALLOWED_HOST_ORIGINS). An unrecognised host still gets the questionnaire, but never a token and never an event.
  • Both sides verify event.origin on every message and drop anything else silently.

Behaviour that is easy to get wrong​

The check-in fails open. It gates every workout start, and the app is built to train offline. If the service does not report ready within READY_TIMEOUT_MS (4s), the sheet completes anyway and the workout starts. Losing a check-in is much cheaper than blocking an athlete from training. See wellness-check-in-sheet.tsx.

Translations without a token. t() needs no provider and no token: with none stored it falls through to the bundled baseline/en.json, so the service renders full English with no network. Translated copy needs the token from the handshake.

The questionnaire wording is data, not display copy. metrics.tsx is module-level, so it cannot call t(). Every string is repeated as an explicit t("…") literal in copy.ts and resolved through localize — the same bridge tracking's technique-tab uses for chart labels. A string added to METRICS but not to copy.ts renders in English rather than breaking.

Session RPE​

The post-workout counterpart: session-rpe.tsx for the flow, scale.ts for the scale. About thirty minutes after training the athlete rates the whole session 0-10. Method: McGuigan & Foster (2004).

One question, and only one. Training load is sRPE x volume, but the volume is not asked for: the app already records the session's sets and reps, so asking the athlete to retype them would invite a worse answer than the one already stored. The questionnaire contributes exactly one number and the server supplies the other from the session's own data.

One thing differs from wellness, and one thing deliberately no longer does:

  • The same ramp as wellness, three stops wide. rpeColor runs green at rest through amber to red at maximal, mixed from --feedback-success / -warning / -error - the tokens wellness's RAMP already uses - with color-mix, so it follows the theme. rpeAccent is the same curve in the saturated weights, for anything drawn thin. Interpolated across each half, so the eleven steps are eleven colours rather than three bands.

    It used to be a single warm hue from pale to full strength, on the argument that a high number here means "hard", not "bad", and that showing an athlete red for a session they are proud of is the wrong message. That argument lost to a simpler one: eleven steps of one hue differ only in strength, which is precisely the comparison a rating scale has to make effortless, and the scale read as a brand gradient rather than as a range.

  • The submit is a number, not a measurement. Wellness posts a bag of MeasurementCreateDtos and has to resolve a metric UUID for each. Session RPE posts { userID, rpe, recordedAt } and the server resolves everything else - see Storing a session RPE below. So submit.ts has no metric lookup in it, and no client-generated idempotency key either.

Every step is named, and three of the names are ours. Foster's printed figure leaves 6, 8 and 9 blank - it names an odd anchor pattern and lets the athlete interpolate - which left three of eleven rows with nothing to read. Both earlier answers to that were worse: resolving each blank to the anchor below it spelled "Very hard" against 7, 8 and 9 alike, presenting three steps as the same answer on a scale whose only job is to tell them apart; leaving them blank made a third of the scale look broken rather than deliberate.

So 6, 8 and 9 read Harder, Extremely hard and Near maximal, and the eight anchors stay Foster's, unchanged and in their original positions. The three are chosen for unambiguous order rather than flavour - a comparative can only read as one step above "Hard" - so the ladder climbs Hard -> Harder -> Very hard -> Extremely hard -> Near maximal -> Maximal with no two steps competing for the same meaning. A rating is stored as its NUMBER and the wording is display only, so this changes how an athlete picks and never what a stored 8 means. scale.test.ts pins all three properties: the anchors, that every step is named, and that no wording appears twice.

Entry points. Only a dashboard card today (today-workouts-pane.tsx), shown unconditionally: there is no eligibility rule yet. Unlike the wellness reminder, the eventual prompt is not owned by the tracking app - an external backend will send a notification once a workout has been complete for thirty minutes.

The backend stores it under its own metric, not exertion​

The rating lands on a sessionRpe metric the backend owns and resolves itself. Worth knowing that this is deliberately not the existing exertion metric, which is a near miss:

exertionsessionRpe
meansreps in reserve (RIR)rating of perceived exertion
scopeone per setone per session
range0-5, step 1, "6+" overflow0-10, step 1
unit symbolRIR-

exertion is entered on the measurement keyboard as the rir input kind (see metrics/keyboard.ts). A 0-10 whole-session rating stored there would be out of range, would render as "8 RIR", and would be indistinguishable from per-set RIR in the same table. Since the endpoint picks its own metric, the client can no longer aim the rating at the wrong one - the separation is now the route's, not a guard in submit.ts.

Persistence​

The service stores the answers itself — the host app never talks to the measurements API. That is the point of it being a service rather than a screen. Both questionnaires POST to the questionnaire microservice (see api/questionnaire.ts), share the one outbox, and report saved / save-failed back over the bridge.

POST /ms/questionnaire/wellness writes one measurement per measure, attributed to the request's userID — not to the caller's JWT.

The server adds a sixth measurement nobody sent. When the request carries exactly the five component measures and no score of its own, it derives a wellnessScore — value the sum of the five (so 5–25), confidence the lowest of them — and returns it among measurements. Miss one component and no score is derived; the rest still store. Nothing on the client asks for this or has to handle it, but it means a check-in produces six rows, and it is the metric the read side should eventually chart rather than re-summing five.

Answering for someone else​

Those two being separate is a feature, not an accident:

  • the token authorises the write (always the signed-in user);
  • the userID says whose check-in it is.

That is what lets a coach answer for an athlete who has no email and never logs in — the case that otherwise leaves those athletes without wellness data entirely. The host picks the subject when it hands over the session: the auth message carries { token, userId }, and host-auth.ts resolves userId || uidFromToken(token), so the host-supplied id wins and the token's own uid is only the fallback for a host that doesn't name one.

Two consequences worth holding on to:

  • A wrong id files the answers silently under the wrong person. Nothing in the flow would show it, which is why the check-in sheet names the subject in its header whenever it isn't the signed-in user.
  • The outbox is scoped per subject (flushOutbox(…, userId) skips other users' entries), so a queued submission for one athlete only drains the next time the questionnaire is opened for that same athlete.

An earlier version of this document said userID was ignored server-side and that a client could not write to another account. That was wrong; the endpoint has always read the id from the body.

Both questionnaires use it​

The mechanism is entirely generic — host-auth.ts, host-bridge.ts and the two submit.ts builders handle the subject identically — so session RPE answers for someone else exactly as wellness does, with no service-side difference.

The two differ only in when the host asks:

WellnessSession RPE
Athlete's ownbefore a workout starts, and a 07:00 remindera requestSessionRpeEvent push
Someone else'sa participant chip, any timea drawer after the workout is finished

Session RPE's on-behalf entrance is post-workout because the rating is multiplied by the session's recorded volume — before the session there is nothing to rate. It lists only participants who completed sets, have no email, and are not the signed-in user (who receives the push like any logged-in athlete). See session-rpe/pending-participants.ts for the rules and docs/push-notifications.md for the push half.

One consequence of the per-subject outbox scoping above: a coach rating several athletes offline leaves one queued entry per athlete, and each only drains when the questionnaire is next opened for that same athlete. They do not all flush on the next reconnect.

Storing a session RPE​

POST /ms/questionnaire/sessionrpe is not a measurements body:

request { userID, rpe, recordedAt, sessionIDs? }
response { userID, recordedAt, measurement, sessionIDs, trainingLoad? }

The server owns the sessionRpe metric and its 0–10 range, so the client sends the number and nothing else — no metric lookup, no measurement wrapper, no client-generated idempotency key. Idempotency is the server's, via unique(metric_id, workout_session_id), restoring a soft-deleted row in place rather than inserting a second one.

The server picks the workout. With sessionIDs omitted — which is what this app does — it clusters: the user's sessions in [recordedAt − 24h, recordedAt], anchored on the most recent, walking backwards while the gap stays under 2h and the session carries no rating yet. It stops at a session that already has one, which is how a second rating of the same workout is refused. Passing ids explicitly overwrites those workouts' rating and load; that is the correction path, and nothing takes it yet.

recordedAt is therefore load-bearing, not a timestamp for the record: it is the anchor the clustering runs from. A rating that waits in the outbox overnight must replay its original value, which is why the queued body is sent verbatim rather than rebuilt at flush time.

Three outcomes, and two of them are not simply "it worked":

sessionIDstrainingLoadmeans
non-emptypresentlinked to a workout, load computed
non-emptyabsentlinked, but no session carried usable volume
emptyabsentnot linked — nothing within 24h, or already rated

The rating is stored against the athlete in all three, so an empty sessionIDs is not a failure. It is the load that needs a workout: no linked session or no volume means there is nothing to multiply, and neither the service panel nor the host may render that as a load of zero. Both say so in words instead — session-rpe.tsx's OutcomeLine and the tracking app's session-rpe-result-drawer.tsx.

This is also why saved carries a payload for session RPE and not for wellness: wellness stores exactly what it was sent, so there is nothing to report back, while here only the response says what actually happened. The bridge carries a SessionRpeResult summary rather than the backend DTO — putting the measurements API on the bridge would be exactly the coupling the service exists to absorb.

Once a rating is on its way, the confirmation panel drops its Edit and Log another buttons. They would be a lie: the server refuses to attach a second rating to an already-rated workout, so "Edit" would not replace the first one — it would file an orphan alongside it.

Metric ids come from the metric keys, which are the MetricKey values themselves — fatigue, sleep, soreness, stress, mood — resolved with getMetricByKey(). That makes the union in wellness/metrics.tsx a backend contract: renaming a value there breaks persistence without breaking the build. submit.test.ts pins it. The service calls loadMetrics() itself, because the portal's root layout mounts no MetricsLoader and a token already in storage skips preloadAfterAuth.

If any measure fails to resolve, nothing is sent — the backend rejects the whole request on one unknown metricID, so a partial body would be a guaranteed 400 that also loses the answers.

Storing never blocks training​

The order in onSubmitted is deliberate: submitted goes to the host first, so the workout starts, and the POST follows. The outcome comes back separately as saved / save-failed. This is the same fail-open principle as the host's load timeout — training must never wait on a network call.

The athlete is shown nothing about a failed save. It is not their problem and not actionable by them, which is why this is the one write in the codebase that does not go through presentError.

The outbox​

outbox.ts persists the built body to localStorage before sending it, and drains on load and on onRetryOpportunity (back online / tab visible). Safe because both endpoints are replay-safe: wellness on the client-generated MeasurementCreateDto.id (replaying the identical body returns 200 with an empty measurements array — success, not failure), session RPE on the server's own per-workout upsert.

One queue holds both questionnaires. An entry carries a kind saying where it goes, and flushOutbox takes one sender per kind and routes by it — the cap, the eviction order, the single-flight drain and the per-subject scoping are the same problem either way, and two queues would drain independently and race. An entry with no kind at all predates session RPE and is read as wellness rather than discarded, so an upgrade never loses somebody's unsent check-in. Entries are told apart by whatever the server treats as one submission: the measurement ids for wellness, subject-and-moment for session RPE.

Write-ahead also removes a race: the tracking sheet reloads this frame shortly after a check-in finishes, which would otherwise abort an in-flight POST.

isTransientUploadError decides keep-vs-drop, the same policy the session upload queue uses. Storage is localStorage rather than the shared IndexedDB outboxes because ADR 0002 ruled those in on raw rep-data volume, and a check-in is five numbers — and because keeping it inside the service folder is what keeps the service liftable.

Open items​

  • Framing is blocked on portaldev, not on portal. Measured 2026-08-21: portaldev.enode.ai answers 302 → Vercel SSO with x-frame-options: DENY, while portal.enode.ai/ and /dashboard answer 200 with no framing header and no CSP. So the DENY comes from the deployment-protection layer that only guards the dev deployment — the same layer open-portal.ts carries a _vercel_share bypass for. Testing the framed service against portaldev therefore needs protection relaxed there and DENY replaced with Content-Security-Policy: frame-ancestors allowing https://localhost and capacitor://localhost. A bypass cookie does not help: it is set in the in-app browser's jar, and the iframe runs in the app's WebView.
  • CORS is satisfied on production. In dev the portal rewrites /api/* to the backend, so the submit is same-origin. A production build calls the backend host directly, cross-origin, and CapacitorHttp does not help — it patches the app's own JS realm, and the iframe is a separate one. Measured 2026-08-21, an OPTIONS https://api.enode.ai/api/metrics preflight from Origin: https://portal.enode.ai returns access-control-allow-origin: * with authorization, api-key, x-user-agent and accept-language all allowed. Worth re-checking if the backend ever narrows that wildcard: the failure mode is quiet — the metric catalogue fails to load, buildSubmission returns null, and the check-in looks like it succeeded while writing nothing.
  • Confirm the five wellness metrics exist. The keys are settled, but no wellness metric appears in any catalogue snapshot in this repo — every metric there is a sensor one. Worth requesting rangeMin: 1, rangeMax: 5, rangeStep: 1 (so isBoundedMetric() treats them as a discrete scale) and a nameTextContentID each (so the names localise like every other server entity). Until they exist, the guard above means no submit is attempted — and the server's wellnessScore needs all five to resolve before it derives anything. Session RPE is not affected: it names no metric.
  • recordedAt is assumed to be epoch ms on the session-RPE submit, taken from submissionDate on the sibling route in the same microservice. If a rating files itself in 1970, that is the assumption to correct — one line in buildSessionRpeSubmission.
  • Charting the three new metrics needs display_bases rows with category userExport for wellnessScore, sessionRpe and trainingLoad. Without them none of the three appears in charts or exports, however well they store. Bucket aggregation is min/avg/max with no sum, so a weekly load total is not chartable until a summing aggregation exists.
  • A rating cannot be corrected from the app. The endpoint supports it — passing sessionIDs explicitly overwrites that workout's rating and load — but nothing surfaces it, and the questionnaire carries no session context to pass. A "re-rate this workout" entry point is where that would go.
  • Sessions uploaded after a rating are never picked up. The clustering runs once, at submit time, over what the backend already holds. An athlete who rates before their offline session finishes uploading gets a rating attached to nothing.
  • Token flavor. The athlete's token is minted under the tracking api-key, while the request carries the portal api-key (sub: portal). The endpoint is token-protected only, so this should be fine — but it is the same unresolved question migration-ios-handoff.md flags.
  • The reads are not wired up. BASELINE in metrics.tsx is still mock data, so the coach summary's readiness call remains illustrative. The handoff's three /api/measurements/* endpoints are the follow-on that would make it real.