Skip to content

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.

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.

PathWhat it does
wp-content/themes/carboncalculator/page-carbon-emissions-report.phpThe report page template (/my-company-emissions/carbon-emissions-report/?report_id=<uuid>)
wp-content/themes/carboncalculator/assets/js/carbon-emissions-report.js5,600 lines of front-end form logic, autosave and step navigation
wp-content/plugins/carboncalc/includes/Rest.php:146GET /carboncalculator/v1/reports/{id}/questionnaire
wp-content/plugins/carboncalc/includes/Rest.php:152POST .../questionnaire
wp-content/plugins/carboncalc/includes/Questionnaire.php:9build_raw() / build_grouped() — assembles the payload
wp-content/plugins/carboncalc/includes/StepYears.phpPer-year step definitions
wp-content/plugins/carboncalc/includes/Rest.php:690POST .../answer (single) and :784 .../answers (batch)
wp-content/plugins/carboncalc/includes/Rest.php:80POST .../clear-step — wipes one step’s answers and meta
TableHolds
cc_stepOne row per (reporting year, step key): title, subtitle, icon, help, sort order
cc_fieldForm inputs: field_key, step_key, group_key, field_type, label, options_json, visibility_json, optional metric_key
cc_metricCalculable quantities: metric_key, step_key, scope (1/2/3), category_key, unit, input_type
cc_categoryGrouping 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.

Questionnaire::build_raw() (Questionnaire.php:9):

  1. Load the report row (scoped to company_id — a mismatch yields []).
  2. StepYears::active_steps_for_reporting_year($year_id).
  3. All active fields and metrics, ordered by step_key, sort_order.
  4. Answers map (metric_key → value) and meta map (meta_key → value).
  5. For each field, resolve saved_value by trying, in order: answers[metric_key], then meta[field_key], then answers[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.

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).

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.

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]].

  • cc_field.field_key and cc_metric.metric_key are 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 in cc_report_answer but nothing reads it, and the report total silently drops. There is no migration helper for this.
  • cc_report_answer.value was DECIMAL before schema v16. Migration 16 (Migrations.php:328) widened it to LONGTEXT because select-style keys like purchased_goods_spend_sic were being coerced — numeric slugs came back as 9.00000000 and broke <option> matching. Do not narrow it.
  • Questionnaire::get_report_row() scopes by company_id, so a grantee viewing a shared report gets an empty report block from build_raw(). Shared-report viewing goes through resolve_report_access_context() in Rest.php instead — see [[report-sharing]].
  • Group titles are a hard-coded map. A new group_key renders as title-cased words unless you add it at Questionnaire.php:163.
  • MetricResolver::slug() collapses every run of non-alphanumerics to _. Two option labels that differ only in punctuation collide into one metric key.
  • A new question with no emissions maths: add a cc_field row with no metric_key. It saves to cc_report_meta under its field_key.
  • A new question that feeds the calculation: add the cc_metric (with scope and category_key), the cc_field pointing at it, and a cc_metric_factor row for every active reporting year. A metric with no factor for the report’s year surfaces in the missing_factors array from POST .../calculate — it does not error, it silently contributes zero.
  • A new multi-part question: add a resolve_*() branch to MetricResolver::resolve() and register the group_key in its switch. Missing that registration returns null and 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_answer and cc_report_meta rows appear, then run the final calculation and confirm missing_factors and missing_metrics are 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]].