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
| Surface | Route | Who opens it |
|---|---|---|
| The service | /questionnaire/ | The tracking app's check-in and session-RPE sheets, in an iframe |
| The console | /dashboard/questionnaire-service | Coaches/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
| Param | Values | Default | Purpose |
|---|---|---|---|
id | wellness | session-rpe | wellness only when absent | Which questionnaire (see registry.ts) |
host | an origin | — | The host's own origin, so the service posts to an explicit target |
audience | athlete | coach | athlete | Which summary to show after the last question |
theme | light | dark | OS preference | Pins the token set to the host's theme |
lang | BCP-47 | navigator.language | Locale 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:
| Source | Value |
|---|---|
window.__ENODE_QUESTIONNAIRE_URL__ | native injection at document start |
NEXT_PUBLIC_QUESTIONNAIRE_URL | web build time — the tunnel escape hatch |
next dev | http://localhost:3001 |
| a dev build | https://portaldev.enode.ai |
| a production build | https://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.aihas no protection layer and sends no framing header.
The host bridge
One contract, both directions, in
host-bridge.ts.
Service → host
| Event | Meaning |
|---|---|
ready | The service loaded. Hosts time out on this to detect an unreachable service. |
authenticated | A token arrived and was accepted; carries userId. |
submitted | The athlete finished. Discriminated on id: wellness carries a CheckInPayload, session-rpe a SessionRpePayload, so a host switching on the id gets the exact shape. |
cancelled | The athlete backed out from inside the service. |
Host → service
| Command | Meaning |
|---|---|
auth | The user's bearer token and id. |
Three transports, tried together — only one exists at a time:
- iframe —
postMessageto the origin declared in?host=. - iOS WKWebView —
window.webkit.messageHandlers.enodeQuestionnaire. - 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
- The host builds the URL with its own origin in
?host=and mounts the frame. - The service posts
ready. - The host verifies
event.origin, then postsauthat the service's origin. - The service verifies
event.origin, holds the token in memory, and postsauthenticated. It deliberately does not callpreloadAfterAuth()or write the token to this origin's storage — see the header comment inhost-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.originon 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.
rpeColorruns green at rest through amber to red at maximal, mixed from--feedback-success/-warning/-error- the tokens wellness'sRAMPalready uses - withcolor-mix, so it follows the theme.rpeAccentis 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. Sosubmit.tshas 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:
exertion | sessionRpe | |
|---|---|---|
| means | reps in reserve (RIR) | rating of perceived exertion |
| scope | one per set | one per session |
| range | 0-5, step 1, "6+" overflow | 0-10, step 1 |
| unit symbol | RIR | - |
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
userIDsays 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
userIDwas 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:
| Wellness | Session RPE | |
|---|---|---|
| Athlete's own | before a workout starts, and a 07:00 reminder | a requestSessionRpeEvent push |
| Someone else's | a participant chip, any time | a 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":
sessionIDs | trainingLoad | means |
|---|---|---|
| non-empty | present | linked to a workout, load computed |
| non-empty | absent | linked, but no session carried usable volume |
| empty | absent | not 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 onportal. Measured 2026-08-21:portaldev.enode.aianswers302→ Vercel SSO withx-frame-options: DENY, whileportal.enode.ai/and/dashboardanswer200with no framing header and no CSP. So theDENYcomes from the deployment-protection layer that only guards the dev deployment — the same layeropen-portal.tscarries a_vercel_sharebypass for. Testing the framed service againstportaldevtherefore needs protection relaxed there andDENYreplaced withContent-Security-Policy: frame-ancestorsallowinghttps://localhostandcapacitor://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, andCapacitorHttpdoes not help — it patches the app's own JS realm, and the iframe is a separate one. Measured 2026-08-21, anOPTIONS https://api.enode.ai/api/metricspreflight fromOrigin: https://portal.enode.aireturnsaccess-control-allow-origin: *withauthorization,api-key,x-user-agentandaccept-languageall allowed. Worth re-checking if the backend ever narrows that wildcard: the failure mode is quiet — the metric catalogue fails to load,buildSubmissionreturns 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(soisBoundedMetric()treats them as a discrete scale) and anameTextContentIDeach (so the names localise like every other server entity). Until they exist, the guard above means no submit is attempted — and the server'swellnessScoreneeds all five to resolve before it derives anything. Session RPE is not affected: it names no metric. recordedAtis assumed to be epoch ms on the session-RPE submit, taken fromsubmissionDateon the sibling route in the same microservice. If a rating files itself in 1970, that is the assumption to correct — one line inbuildSessionRpeSubmission.- Charting the three new metrics needs
display_basesrows with categoryuserExportforwellnessScore,sessionRpeandtrainingLoad. 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
sessionIDsexplicitly 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 questionmigration-ios-handoff.mdflags. - The reads are not wired up.
BASELINEinmetrics.tsxis 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.