Carbon report questionnaire
The questionnaire is the multi-step form a company fills in to declare its emissions activity for a reporting year. Its structure is not hard-coded: steps, fields, metrics and categories are database rows, so the form can be changed per reporting year without a deploy.
Why it exists
Section titled “Why it exists”Defra’s greenhouse-gas reporting factors and the questions needed to apply
them change every year. A hard-coded form would need a release each April.
Instead the form is data: cc_step rows are scoped per reporting year, and
cc_field rows describe inputs, options and conditional visibility.
Entry points
Section titled “Entry points”| Path | What it does |
|---|---|
wp-content/themes/carboncalculator/page-carbon-emissions-report.php | The report page template (/my-company-emissions/carbon-emissions-report/?report_id=<uuid>) |
wp-content/themes/carboncalculator/assets/js/carbon-emissions-report.js | 5,600 lines of front-end form logic, autosave and step navigation |
wp-content/plugins/carboncalc/includes/Rest.php:146 | GET /carboncalculator/v1/reports/{id}/questionnaire |
wp-content/plugins/carboncalc/includes/Rest.php:152 | POST .../questionnaire |
wp-content/plugins/carboncalc/includes/Questionnaire.php:9 | build_raw() / build_grouped() — assembles the payload |
wp-content/plugins/carboncalc/includes/StepYears.php | Per-year step definitions |
wp-content/plugins/carboncalc/includes/Rest.php:690 | POST .../answer (single) and :784 .../answers (batch) |
wp-content/plugins/carboncalc/includes/Rest.php:80 | POST .../clear-step — wipes one step’s answers and meta |
How it works
Section titled “How it works”Data model
Section titled “Data model”| Table | Holds |
|---|---|
cc_step | One row per (reporting year, step key): title, subtitle, icon, help, sort order |
cc_field | Form inputs: field_key, step_key, group_key, field_type, label, options_json, visibility_json, optional metric_key |
cc_metric | Calculable quantities: metric_key, step_key, scope (1/2/3), category_key, unit, input_type |
cc_category | Grouping for metrics, with a scope |
cc_report_answer | (report_id, metric_key) → value — LONGTEXT, one row per answered metric |
cc_report_meta | (report_id, meta_key) → value — non-metric answers (control questions, turnover, confirmations) |
Note the asymmetry: cc_step is per-year (UNIQUE KEY year_step), but
cc_field, cc_metric and cc_category are global — field_key,
metric_key and category_key are each globally unique. Only conversion
factors (cc_metric_factor) and steps are year-scoped.
Assembling the form
Section titled “Assembling the form”Questionnaire::build_raw() (Questionnaire.php:9):
- Load the report row (scoped to
company_id— a mismatch yields[]). StepYears::active_steps_for_reporting_year($year_id).- All active fields and metrics, ordered by
step_key,sort_order. - Answers map (
metric_key → value) and meta map (meta_key → value). - For each field, resolve
saved_valueby trying, in order:answers[metric_key], thenmeta[field_key], thenanswers[field_key].
That third fallback exists because some field-key-only rows (for example
purchased_goods_spend_sic) are saved through POST /answer into
cc_report_answer rather than into meta — see the comment at
Questionnaire.php:45.
build_grouped() then buckets fields by group_key, falling back to the
metric’s category_key, then to the first _-delimited segment of the
field key, then general (derive_group_key_from_field_row(),
Questionnaire.php:139). Group titles come from a hard-coded map
(Questionnaire.php:163) with a ucwords(str_replace('_',' ')) fallback.
Metric resolution
Section titled “Metric resolution”Many steps ask a chain of questions (fuel type → unit → amount) that
together identify one metric. MetricResolver::resolve($group_key, $answers)
(includes/MetricResolver.php:9) turns the answer set into a single
metric_key. It handles: company fleet, stationary combustion, mobile
combustion, fugitive emissions, purchased goods & services, water, waste.
Keys are built by slugging and concatenating, e.g.
fuel_{type}_{unit}, mobile_fuel_{type}_{unit},
waste_{type}_{disposal}_{unit}, spend_{sic}_{currency}
(MetricResolver.php:126, :142, :276, :223). The sentinel
__none__ means “not applicable” and is stripped before key assembly
(MetricResolver.php:190).
The resolver returns the first populated candidate, so when a user
answers “both fuel and distance”, fuel wins for the single-value path.
Multi-row inputs are handled separately by the
resolve_*_questionnaire_items() and extract_mobile_*_rows() helpers in
Rest.php (:2231–:2596).
Saving
Section titled “Saving”Answers autosave from the front end. POST .../answers batches them;
normalise_answer_value() and persist_report_answer_value()
(Rest.php:886, :891) do the write, keyed on the
UNIQUE KEY report_metric (report_id, metric_key) upsert.
expand_metric_keys_for_answers() (Rest.php:2597) fans one incoming key
out to several stored keys where a question feeds multiple metrics.
POST .../clear-step deletes both the answers and the meta for a step
(clear_report_step_section_data(), Rest.php:1923, and
delete_report_answers_for_step(), :1991) — used when a user answers
“no” to a control question and the sub-answers must not linger and
inflate the total.
Configuration
Section titled “Configuration”No constants. Everything is data, edited in wp-admin → Carbon Calculator → Steps / Fields / Metrics / Categories, or bulk-loaded through the CSV importer — see [[reporting-year-config-and-importer]].
Invariants and gotchas
Section titled “Invariants and gotchas”cc_field.field_keyandcc_metric.metric_keyare globally unique, not per-year. You cannot have two variants of the same field for different years. Only steps and factors are year-scoped. Changing a field’s meaning retroactively changes how last year’s stored answers are rendered.- Answers are keyed by
metric_key, not by field. Rename a metric key and every stored answer for it is orphaned — the value stays incc_report_answerbut nothing reads it, and the report total silently drops. There is no migration helper for this. cc_report_answer.valuewasDECIMALbefore schema v16. Migration 16 (Migrations.php:328) widened it toLONGTEXTbecause select-style keys likepurchased_goods_spend_sicwere being coerced — numeric slugs came back as9.00000000and broke<option>matching. Do not narrow it.Questionnaire::get_report_row()scopes bycompany_id, so a grantee viewing a shared report gets an emptyreportblock frombuild_raw(). Shared-report viewing goes throughresolve_report_access_context()inRest.phpinstead — see [[report-sharing]].- Group titles are a hard-coded map. A new
group_keyrenders as title-cased words unless you add it atQuestionnaire.php:163. MetricResolver::slug()collapses every run of non-alphanumerics to_. Two option labels that differ only in punctuation collide into one metric key.
Changing it safely
Section titled “Changing it safely”- A new question with no emissions maths: add a
cc_fieldrow with nometric_key. It saves tocc_report_metaunder itsfield_key. - A new question that feeds the calculation: add the
cc_metric(withscopeandcategory_key), thecc_fieldpointing at it, and acc_metric_factorrow for every active reporting year. A metric with no factor for the report’s year surfaces in themissing_factorsarray fromPOST .../calculate— it does not error, it silently contributes zero. - A new multi-part question: add a
resolve_*()branch toMetricResolver::resolve()and register thegroup_keyin itsswitch. Missing that registration returnsnulland the answers never become a metric. - After changing steps for one year, check the year copy logic in
Migrations.php:361— schema v18 copies step rows into years that have none, so an empty year inherits the earliest year’s steps. - Verify by hand: open a draft report, walk every step, check
cc_report_answerandcc_report_metarows appear, then run the final calculation and confirmmissing_factorsandmissing_metricsare both empty. - Deliberately not abstracted:
MetricResolver’s long lists of literal metric keys (MetricResolver.php:62-100). They mirror the Defra factor names exactly and are intentionally readable against the source spreadsheet.
None. MetricResolver is a pure function of an answers array and is the
obvious first unit-test target.
Related: [[emissions-calculation-and-submission]], [[reporting-year-config-and-importer]], [[company-emissions-dashboard]].