Exports
Every deliverable is generated in the browser from the dataset's summary and re-scored rows (apps/web/src/exports). The server never renders documents. Heavy libraries (ExcelJS, pptxgenjs) are imported dynamically when someone exports.
Every HTML, PowerPoint and Excel export takes a DeliverableBrand from brand.ts (Davies by default): the palette (paletteFor in @workbench/core), the font, the cover, footer and sheet logos, and the Prepared by Davies credit. Exports never write a colour literal; a client's brand comes from GET /api/clients/:id/brand and the consultant picks Davies, co-branded or client-only with BrandPicker.
| Module | Produces |
|---|---|
workforceReport.ts | The workforce AI-impact HTML report: buildWorkforceReportHtml and the Markdown copy (workforceMarkdown), with the engagement's report wording applied. Re-exports REPORT_SECTIONS, which now lives in core (report-notes.ts). |
orgReport.ts | The spans and layers report: buildOrgReportHtml, orgReportMarkdown and orgManagersCsv, from core's buildOrgReport model. |
wording.ts | Standard wording shared by the in-app report, the HTML report and the deck (SCENARIO_NOTE, SCURVE_NOTE, OFFSHORE_NOTE, EXPLORER_NOTE), so they never differ. |
copilotReport.ts | The Copilot business case HTML report, optionally with a rollout plan. |
htmlShell.ts | The shared self-contained HTML shell: cover, section navigation, deck (presentation) mode, print styles, the Markdown download, the Teams/Outlook preview notice; downloadHtmlFile, previewHtmlInTab, printHtmlInIframe. |
pptx.ts | downloadWorkforceDeck and downloadRolloutDeck with pptxgenjs: the Davies master, brand-ordered chart series. |
workbook.ts | Renders core's buildWorkbookModel to Excel with ExcelJS: styles, number formats, formulas, editable assumption cells. |
allocation.ts | The per-tenant Copilot allocation workbook. |
provenance.ts | The methodology and sources sections (from metricProvenanceList), and basisLine(ref, key): a one-line basis, what and sources note for the end of a section. |
brand.ts | daviesBranding(title, withLogo): colours, fonts and the embedded logo for deliverables. |
files.ts | csvCell, toCsv (with a BOM for Excel), downloadBlob, downloadText. |
The HTML shell
Reports are single, self-contained HTML files: styles, scripts, data and the logo are embedded, and there are no network requests, so they open offline and can be emailed. The shell provides:
- a flat teal cover with the white logo and a salmon rule, and section navigation;
- ▶ Deck: presentation mode built from the rendered sections, with arrow keys, space, a jump menu, a progress bar, ⎙ PDF and ✕ Exit (Escape);
- ⬇ MD: the report as Markdown (
window.__COGS_MD); - print styles for Print / PDF (the interactive explorer is omitted when printing);
- a notice shown when the file is opened in a script-blocking preview (Teams, Outlook) asking the reader to download it.
All content is escaped; model text is rendered from Markdown without raw HTML.
The interactive explorer embeds rows by employee ID, role and cost only: never names.
Report wording
An engagement's reportNotes (ReportNotes in packages/core/src/report-notes.ts, stored in engagements.report_notes) reach the workforce report and deck as notes:
sections[key]: paragraphs (split on blank lines bynoteParagraphs) appended to a section. The HTML report adds them to every section inREPORT_SECTIONS; the Markdown copy to the sections it has a part for, and the rest under a closing Further notes heading; the deck adds a slide straight after the section's own (noteAfterinpptx.ts), sobusinesses,taxonomy,benchmark,reinvestmentandexplorernotes are report-only.valueConcentration:withValueConcentrationfinds the executive summary's value point (Where the value concentrates, Where value concentrates or Value concentration, as a paragraph, bullet or heading) and replaces the whole point, including continuation lines or a heading's body, or appends one when there's none. WhenhasOwnValuePoint(notes), the HTML report, the Markdown and the deck addOWN_POINT(wording.ts), The value concentration point is the engagement team's own wording., beside the AI-written or rule-based label.terms:applyTermsmatches eachfromas a whole word or phrase (not inside a longer word), ignoring case, and gives the replacement the capitals of the text it replaces, keeping a replacement that starts with two capitals (an acronym such as BAU) as written.reportNotesSchemarequires afromwith a letter and refuses digits,£,$,€and%, so a swap can't alter a figure.applyTermsToHtmlswaps text between tags only (never tags, attributes,<script>or<style>): each text node is decoded, swapped and re-escaped, so the replacement is always text. The HTML report applies swaps to each section, the section menu labels, the hero subtitle and meta line, the head and Open Graph descriptions and the cover statistics' labels, but not to the methodology section. In the deck,newDeckwrapsaddSlideso every slide'saddTextandaddTablepass throughapplyTerms, except while the methodology and sources slides are built (d.swap.active = false): sources are cited as published.
The notes never change a figure. The finance workbook and the spans report don't use them.
The spans and layers report
buildOrgReport(employees, ref.data.org) (packages/core/src/org-report.ts) computes everything the report prints from the scored rows: the structure, span bands, layers, structure by business, division and department, the cost of managers, every manager (most expensive first), and reportingLineQuality: connected and disconnected people, the missing managers with their reports, and a sensitivity that re-runs the structure with each missing manager added as one root. orgReport.ts renders it with the shared shell; each section ends with a basisLine for orgStructure, spanThresholds, orgDisconnected or managementCost, and the methodology section covers those plus salary and employerMultiplier. Managers appear by employee ID and job title; a stand-in ID from upload (UNMATCHED_MANAGER_PREFIX, unmatched-manager-N) reads as Manager named by email, not in the list (#N), and missingManagerLabel(id, n) never prints a reference that has an @ (or a full-width @) or whitespace or is longer than 40 characters, which may be an email or a name typed into the manager column: it reads Unrecognised manager reference (#n). Layers and the non-interactive manager list also cite managementCost. A staff list with no reporting lines (summary.org.hasHierarchy false) gets a one-section report saying so, and the Exports card shows a message instead of its buttons.
The finance workbook
The workbook model is pure and lives in core (packages/core/src/workbook.ts), so its structure and formulas are unit-tested: SHEET names the fixed sheets (ten always, plus By Division and By Department when the staff list has those values), a By label sheet is added per client attribute (named safely for Excel: no \ / ? * [ ] :, no leading or trailing apostrophe, at most 31 characters, unique ignoring case with 2, 3… on a clash, and By grouping N when nothing is left after cleaning), and ASSUMPTION_CELLS maps each editable parameter (scenario rates, the employer-cost multiplier) to a cell on Assumptions. Every saving on the other sheets is a formula referencing those cells; Raw Data also carries each person's multiplier (moderate saving ÷ cost), FTE and FTE saved (multiplier × FTE) as formulas, and every group sheet sums FTE saved. workbook.test.ts asserts the formulas reproduce the platform's scenario figures exactly. Group totals use exact, case-sensitive matches (SUMPRODUCT(--EXACT(…))), so titles or divisions containing wildcards, or differing only in case, never pool in Excel. The web renderer only applies presentation: Calibri, teal header rows with white text, 8% teal banding, number formats, frozen headers and auto-filters, and highlighting of the editable cells.
Decks
pptx.ts builds decks with a Davies master: the white logo on a teal title slide, Calibri throughout and chart series in brand order (teal, salmon, light teal, mint, grey, then 60% tints). The workforce deck takes the summary, the executive summary markdown and the engagement's report wording (see Report wording); its value-chain slide uses the summary's rule-based lifecycle (it isn't passed the AI map, unlike the HTML report). The rollout deck takes a computed rollout result.
The allocation workbook
allocation.ts writes a Summary sheet, one sheet per tenant of new licences and an Existing licences sheet. When the exporting user has PII access and the dataset has stored identities, the Rollout tab first calls POST /identities/reveal with the purpose Copilot allocation workbook: and the plan name for everyone in the plan, and the workbook gains Name and Email columns; the export is then recorded with named: true.
CSV safety
csvCell prefixes any value starting with =, +, -, @, a tab or a carriage return with an apostrophe, so a hostile job title such as =HYPERLINK(…) can't run as a formula when the CSV is opened in Excel. Numbers are left as numbers. toCsv adds a UTF-8 BOM so Excel reads £ and accented characters correctly. The reference data editor's CSV import strips the guard again so values round-trip.
Auditing
After a successful export the page calls api.recordExport(datasetId, { kind, artefact, named? }), which audits export_generated. This is fire-and-forget from the browser: the server records what the client reports.
Adding a deliverable
- Put any figures it needs in core (so they match the platform), with provenance.
- Build it in
apps/web/src/exports, importing heavy libraries dynamically. - Include a methodology and sources section from
metricProvenanceList. - Never include names or emails, unless the deliverable is explicitly a named export behind an audited reveal.
- Add a card to
pages/dataset/ExportsTab.tsx(or the relevant tab) that records the export. - Test the pure parts in
apps/web/test(exports.test.ts, or a file of its own asorg-report.test.tsandreport-notes.test.tsare).